From 5375c002cf3548ed804e7d604e356b863c18e3ac Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Mon, 30 Mar 2026 15:04:38 +0200 Subject: [PATCH 01/31] Implement course widget basic architecture --- libs/horizon/build.gradle.kts | 6 + .../horizon/database/HorizonDatabase.kt | 45 +++++++ .../database/HorizonDatabaseProvider.kt | 37 ++++++ .../course/HorizonDashboardCourseDao.kt | 51 ++++++++ .../course/HorizonDashboardCourseEntity.kt | 33 +++++ .../HorizonDashboardModuleItemDao.kt | 34 +++++ .../HorizonDashboardModuleItemEntity.kt | 34 +++++ .../HorizonDashboardProgramCourseRef.kt | 28 +++++ .../program/HorizonDashboardProgramDao.kt | 46 +++++++ .../program/HorizonDashboardProgramEntity.kt | 25 ++++ .../database/sync/HorizonSyncMetadataDao.kt | 31 +++++ .../sync/HorizonSyncMetadataEntity.kt | 29 +++++ .../horizon/di/HorizonOfflineModule.kt | 62 ++++++++++ .../features/dashboard/DashboardScreen.kt | 3 + .../features/dashboard/DashboardUiState.kt | 2 + .../features/dashboard/DashboardViewModel.kt | 26 +++- .../features/dashboard/OfflineBanner.kt | 80 ++++++++++++ .../course/DashboardCourseDataSource.kt | 26 ++++ .../course/DashboardCourseLocalDataSource.kt | 117 ++++++++++++++++++ .../DashboardCourseNetworkDataSource.kt | 59 +++++++++ .../course/DashboardCourseRepository.kt | 71 +++++++---- .../widget/course/DashboardCourseSyncer.kt | 109 ++++++++++++++++ .../widget/course/DashboardCourseViewModel.kt | 67 +++++----- .../offline/HorizonOfflineRepository.kt | 31 +++++ .../offline/HorizonOfflineViewModel.kt | 49 ++++++++ .../instructure/horizon/offline/SyncPolicy.kt | 21 ++++ .../src/main/res/drawable/ic_no_wifi.xml | 9 ++ libs/horizon/src/main/res/values/strings.xml | 2 + .../pandautils/utils/FeatureFlagProvider.kt | 2 +- 29 files changed, 1076 insertions(+), 59 deletions(-) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabaseProvider.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/course/HorizonDashboardCourseDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/course/HorizonDashboardCourseEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/moduleitem/HorizonDashboardModuleItemDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/moduleitem/HorizonDashboardModuleItemEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramCourseRef.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/sync/HorizonSyncMetadataDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/sync/HorizonSyncMetadataEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/OfflineBanner.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseLocalDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseNetworkDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseSyncer.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonOfflineRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonOfflineViewModel.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/offline/SyncPolicy.kt create mode 100644 libs/horizon/src/main/res/drawable/ic_no_wifi.xml 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/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..ed33d76ed2 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.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 + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.instructure.horizon.database.course.HorizonDashboardCourseDao +import com.instructure.horizon.database.course.HorizonDashboardCourseEntity +import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemDao +import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemEntity +import com.instructure.horizon.database.program.HorizonDashboardProgramCourseRef +import com.instructure.horizon.database.program.HorizonDashboardProgramDao +import com.instructure.horizon.database.program.HorizonDashboardProgramEntity +import com.instructure.horizon.database.sync.HorizonSyncMetadataDao +import com.instructure.horizon.database.sync.HorizonSyncMetadataEntity + +@Database( + entities = [ + HorizonDashboardCourseEntity::class, + HorizonDashboardProgramEntity::class, + HorizonDashboardProgramCourseRef::class, + HorizonDashboardModuleItemEntity::class, + HorizonSyncMetadataEntity::class, + ], + version = 1, +) +abstract class HorizonDatabase : RoomDatabase() { + abstract fun dashboardCourseDao(): HorizonDashboardCourseDao + 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/course/HorizonDashboardCourseDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/course/HorizonDashboardCourseDao.kt new file mode 100644 index 0000000000..f9fe9de0bf --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/course/HorizonDashboardCourseDao.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.database.course + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class HorizonDashboardCourseDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertAll(entities: List) + + @Query("SELECT * FROM horizon_dashboard_courses") + abstract fun observeAll(): Flow> + + @Query("SELECT * FROM horizon_dashboard_courses") + abstract suspend fun getAll(): List + + @Query("SELECT courseId FROM horizon_dashboard_courses") + abstract suspend fun getAllCourseIds(): List + + @Query("SELECT COUNT(*) FROM horizon_dashboard_courses") + abstract suspend fun count(): Int + + @Query("DELETE FROM horizon_dashboard_courses") + abstract suspend fun deleteAll() + + @Transaction + open suspend fun replaceAll(entities: List) { + deleteAll() + insertAll(entities) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/course/HorizonDashboardCourseEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/course/HorizonDashboardCourseEntity.kt new file mode 100644 index 0000000000..bfa218c58f --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/course/HorizonDashboardCourseEntity.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.course + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "horizon_dashboard_courses", + indices = [Index("courseId")] +) +data class HorizonDashboardCourseEntity( + @PrimaryKey val enrollmentId: Long, + val courseId: Long, + val courseName: String, + val courseImageUrl: String?, + val completionPercentage: Double, + val enrollmentState: String, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/moduleitem/HorizonDashboardModuleItemDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/moduleitem/HorizonDashboardModuleItemDao.kt new file mode 100644 index 0000000000..6537a25fd0 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/moduleitem/HorizonDashboardModuleItemDao.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.moduleitem + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@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") + suspend fun deleteAll() +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/moduleitem/HorizonDashboardModuleItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/moduleitem/HorizonDashboardModuleItemEntity.kt new file mode 100644 index 0000000000..76b5ee79e5 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/moduleitem/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.moduleitem + +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/program/HorizonDashboardProgramCourseRef.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramCourseRef.kt new file mode 100644 index 0000000000..110b40fe33 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramCourseRef.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.program + +import androidx.room.Entity + +@Entity( + tableName = "horizon_dashboard_program_course_refs", + primaryKeys = ["programId", "courseId"] +) +data class HorizonDashboardProgramCourseRef( + val programId: String, + val courseId: Long, + val enrollmentStatus: String?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramDao.kt new file mode 100644 index 0000000000..b072bb65f6 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramDao.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.program + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@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 courseId = :courseId") + suspend fun getRefsForCourse(courseId: Long): 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() +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramEntity.kt new file mode 100644 index 0000000000..1d3202f7f9 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramEntity.kt @@ -0,0 +1,25 @@ +/* + * 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.program + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "horizon_dashboard_programs") +data class HorizonDashboardProgramEntity( + @PrimaryKey val programId: String, + val programName: String, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/sync/HorizonSyncMetadataDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/sync/HorizonSyncMetadataDao.kt new file mode 100644 index 0000000000..0fdb2830a4 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/sync/HorizonSyncMetadataDao.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.sync + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface HorizonSyncMetadataDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: HorizonSyncMetadataEntity) + + @Query("SELECT lastSyncedAtMs FROM horizon_sync_metadata WHERE `key` = :key") + suspend fun getLastSyncedAt(key: String): Long? +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/sync/HorizonSyncMetadataEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/sync/HorizonSyncMetadataEntity.kt new file mode 100644 index 0000000000..f555591a8e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/sync/HorizonSyncMetadataEntity.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.sync + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "horizon_sync_metadata") +data class HorizonSyncMetadataEntity( + @PrimaryKey val key: String, + val lastSyncedAtMs: Long, +) { + companion object { + const val KEY_DASHBOARD_COURSES = "dashboard_courses" + } +} 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..9fa1820055 --- /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.course.HorizonDashboardCourseDao +import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemDao +import com.instructure.horizon.database.program.HorizonDashboardProgramDao +import com.instructure.horizon.database.sync.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 provideHorizonDashboardCourseDao(db: HorizonDatabase): HorizonDashboardCourseDao { + return db.dashboardCourseDao() + } + + @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/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..a2b7b1308a 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 @@ -17,18 +17,24 @@ package com.instructure.horizon.features.dashboard import android.content.Context import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow 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.sync.HorizonSyncMetadataDao +import com.instructure.horizon.database.sync.HorizonSyncMetadataEntity +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 import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -40,7 +46,10 @@ class DashboardViewModel @Inject constructor( private val apiPrefs: ApiPrefs, private val themePrefs: ThemePrefs, private val localeUtils: LocaleUtils, - private val dashboardEventHandler: DashboardEventHandler + private val dashboardEventHandler: DashboardEventHandler, + private val networkStateProvider: NetworkStateProvider, + private val featureFlagProvider: FeatureFlagProvider, + private val syncMetadataDao: HorizonSyncMetadataDao, ) : ViewModel() { private val _uiState = MutableStateFlow(DashboardUiState(onSnackbarDismiss = ::dismissSnackbar, updateExternalShouldRefresh = ::updateExternalShouldRefresh)) @@ -66,6 +75,21 @@ class DashboardViewModel @Inject constructor( } } } + + viewModelScope.launch { + networkStateProvider.isOnlineLiveData.asFlow() + .distinctUntilChanged() + .collect { isOnline -> + if (featureFlagProvider.offlineEnabled()) { + val lastSyncedAt = if (!isOnline) { + syncMetadataDao.getLastSyncedAt(HorizonSyncMetadataEntity.KEY_DASHBOARD_COURSES) + } else { + null + } + _uiState.update { it.copy(isOffline = !isOnline, lastSyncedAtMs = lastSyncedAt) } + } + } + } } private suspend fun loadLogo() { 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/DashboardCourseDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseDataSource.kt new file mode 100644 index 0000000000..64fbbac62d --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseDataSource.kt @@ -0,0 +1,26 @@ +/* + * 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.widget.course + +import com.instructure.canvasapi2.GetCoursesQuery +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.models.ModuleObject + +interface DashboardCourseDataSource { + suspend fun getEnrollments(): List + suspend fun getPrograms(): List + suspend fun getModuleItemsForCourse(courseId: Long): List +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseLocalDataSource.kt new file mode 100644 index 0000000000..b0371ba8ec --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseLocalDataSource.kt @@ -0,0 +1,117 @@ +/* + * 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.widget.course + +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.ModuleContentDetails +import com.instructure.canvasapi2.models.ModuleItem +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.canvasapi2.type.EnrollmentWorkflowState +import com.instructure.horizon.database.course.HorizonDashboardCourseDao +import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemDao +import com.instructure.horizon.database.program.HorizonDashboardProgramDao +import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject + +class DashboardCourseLocalDataSource @Inject constructor( + private val courseDao: HorizonDashboardCourseDao, + private val programDao: HorizonDashboardProgramDao, + private val moduleItemDao: HorizonDashboardModuleItemDao, +) : DashboardCourseDataSource { + + override suspend fun getEnrollments(): List { + return courseDao.getAll().map { entity -> + GetCoursesQuery.Enrollment( + id = entity.enrollmentId.toString(), + state = EnrollmentWorkflowState.safeValueOf(entity.enrollmentState), + lastActivityAt = null, + course = GetCoursesQuery.Course( + id = entity.courseId.toString(), + name = entity.courseName, + image_download_url = entity.courseImageUrl, + syllabus_body = null, + account = null, + usersConnection = GetCoursesQuery.UsersConnection( + nodes = listOf( + GetCoursesQuery.Node( + courseProgression = GetCoursesQuery.CourseProgression( + requirements = GetCoursesQuery.Requirements( + completionPercentage = entity.completionPercentage, + ), + incompleteModulesConnection = null, + ) + ) + ) + ), + ) + ) + } + } + + override suspend fun getPrograms(): List { + val programs = programDao.getAll() + return programs.map { programEntity -> + val refs = programDao.getRefsForProgram(programEntity.programId) + Program( + id = programEntity.programId, + name = programEntity.programName, + description = null, + startDate = null, + endDate = null, + variant = com.instructure.journey.type.ProgramVariantType.UNKNOWN__, + sortedRequirements = refs.map { ref -> + ProgramRequirement( + id = "", + progressId = "", + courseId = ref.courseId, + required = false, + enrollmentStatus = ref.enrollmentStatus?.let { + ProgramProgressCourseEnrollmentStatus.safeValueOf(it) + }, + ) + } + ) + } + } + + override suspend fun getModuleItemsForCourse(courseId: Long): List { + val entity = moduleItemDao.getFirstForCourse(courseId) ?: return emptyList() + val moduleItem = ModuleItem( + id = entity.moduleItemId, + moduleId = 0L, + title = entity.moduleItemTitle, + type = entity.moduleItemType, + quizLti = entity.isQuizLti, + estimatedDuration = entity.estimatedDuration, + moduleDetails = entity.dueDateMs?.let { ms -> + ModuleContentDetails(dueAt = isoFormatter.format(Date(ms))) + }, + ) + return listOf(ModuleObject(items = listOf(moduleItem))) + } + + companion object { + private val isoFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+00:00", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseNetworkDataSource.kt new file mode 100644 index 0000000000..6fbe2a29b9 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseNetworkDataSource.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.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 DashboardCourseNetworkDataSource @Inject constructor( + private val horizonGetCoursesManager: HorizonGetCoursesManager, + private val moduleApi: ModuleAPI.ModuleInterface, + private val apiPrefs: ApiPrefs, + private val enrollmentApi: EnrollmentAPI.EnrollmentInterface, + private val getProgramsManager: GetProgramsManager, +) : DashboardCourseDataSource { + + override suspend fun getEnrollments(): List { + return horizonGetCoursesManager.getEnrollments(apiPrefs.user?.id ?: -1, forceNetwork = true).dataOrThrow + } + + override suspend fun getPrograms(): List { + return getProgramsManager.getPrograms(forceNetwork = true) + } + + override suspend fun getModuleItemsForCourse(courseId: Long): List { + val params = RestParams(isForceReadFromNetwork = true) + return moduleApi.getFirstPageModulesWithItems( + CanvasContext.Type.COURSE.apiString, + courseId, + params, + includes = listOf("estimated_durations") + ).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/features/dashboard/widget/course/DashboardCourseRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseRepository.kt index fa2bae79c2..5edff1b821 100644 --- 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 @@ -17,43 +17,62 @@ 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 com.instructure.horizon.offline.HorizonOfflineRepository +import com.instructure.horizon.offline.SyncPolicy +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider 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, + private val networkDataSource: DashboardCourseNetworkDataSource, + private val localDataSource: DashboardCourseLocalDataSource, + private val localDataSync: DashboardCourseSyncer, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : HorizonOfflineRepository( + localDataSource = localDataSource, + networkDataSource = networkDataSource, + networkStateProvider = networkStateProvider, + featureFlagProvider = featureFlagProvider, ) { - suspend fun getEnrollments(forceNetwork: Boolean): List { - return horizonGetCoursesManager.getEnrollments(apiPrefs.user?.id ?: -1, forceNetwork).dataOrThrow + + suspend fun getEnrollments(): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getEnrollments().also { enrollments -> + if (isOfflineEnabled()) localDataSync.syncCourses(enrollments, SyncPolicy.ALWAYS_REPLACE) + } + } else { + localDataSource.getEnrollments() + } } suspend fun acceptInvite(courseId: Long, enrollmentId: Long) { - return enrollmentApi.acceptInvite(courseId, enrollmentId, RestParams()).dataOrThrow + networkDataSource.acceptInvite(courseId, enrollmentId) } - suspend fun getPrograms(forceNetwork: Boolean = false): List { - return getProgramsManager.getPrograms(forceNetwork) + suspend fun getPrograms(): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getPrograms().also { programs -> + if (isOfflineEnabled()) localDataSync.syncPrograms(programs, SyncPolicy.ALWAYS_REPLACE) + } + } else { + localDataSource.getPrograms() + } } - 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 + suspend fun getModuleItemsForCourse(courseId: Long): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getModuleItemsForCourse(courseId).also { modules -> + if (isOfflineEnabled()) localDataSync.syncModuleItem(courseId, modules, SyncPolicy.ALWAYS_REPLACE) + } + } else { + localDataSource.getModuleItemsForCourse(courseId) + } } -} \ No newline at end of file + + suspend fun getLastSyncedAt(): Long? = localDataSync.getLastSyncedAt() + + private suspend fun shouldFetchFromNetwork() = isOnline() || !isOfflineEnabled() +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseSyncer.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseSyncer.kt new file mode 100644 index 0000000000..8955021cab --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseSyncer.kt @@ -0,0 +1,109 @@ +/* + * 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.widget.course + +import com.instructure.canvasapi2.GetCoursesQuery +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.database.course.HorizonDashboardCourseDao +import com.instructure.horizon.database.course.HorizonDashboardCourseEntity +import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemDao +import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemEntity +import com.instructure.horizon.database.program.HorizonDashboardProgramCourseRef +import com.instructure.horizon.database.program.HorizonDashboardProgramDao +import com.instructure.horizon.database.program.HorizonDashboardProgramEntity +import com.instructure.horizon.database.sync.HorizonSyncMetadataDao +import com.instructure.horizon.database.sync.HorizonSyncMetadataEntity +import com.instructure.horizon.model.LearningObjectType +import com.instructure.horizon.offline.SyncPolicy +import javax.inject.Inject + +class DashboardCourseSyncer @Inject constructor( + private val courseDao: HorizonDashboardCourseDao, + private val programDao: HorizonDashboardProgramDao, + private val moduleItemDao: HorizonDashboardModuleItemDao, + private val syncMetadataDao: HorizonSyncMetadataDao, +) { + + suspend fun syncCourses(enrollments: List, policy: SyncPolicy) { + if (policy == SyncPolicy.SKIP_IF_PRESENT && courseDao.count() > 0) return + val entities = enrollments.mapNotNull { enrollment -> + val course = enrollment.course ?: return@mapNotNull null + val completionPercentage = course.usersConnection?.nodes + ?.firstOrNull()?.courseProgression?.requirements?.completionPercentage ?: 0.0 + HorizonDashboardCourseEntity( + enrollmentId = enrollment.id?.toLongOrNull() ?: return@mapNotNull null, + courseId = course.id.toLongOrNull() ?: return@mapNotNull null, + courseName = course.name, + courseImageUrl = course.image_download_url, + completionPercentage = completionPercentage, + enrollmentState = enrollment.state.rawValue, + ) + } + courseDao.replaceAll(entities) + if (policy == SyncPolicy.ALWAYS_REPLACE) updateLastSync() + } + + suspend fun syncPrograms(programs: List, policy: SyncPolicy) { + if (policy == SyncPolicy.SKIP_IF_PRESENT) return + val courseIds = courseDao.getAllCourseIds().toSet() + val programEntities = programs.map { HorizonDashboardProgramEntity(it.id, it.name) } + val refs = programs.flatMap { program -> + program.sortedRequirements + .filter { it.courseId in courseIds } + .map { req -> + HorizonDashboardProgramCourseRef( + programId = program.id, + courseId = req.courseId, + enrollmentStatus = req.enrollmentStatus?.rawValue, + ) + } + } + programDao.deleteAllRefs() + programDao.deleteAll() + programDao.insertAll(programEntities) + programDao.insertAllRefs(refs) + } + + suspend fun syncModuleItem(courseId: Long, modules: List, policy: SyncPolicy) { + if (policy == SyncPolicy.SKIP_IF_PRESENT) return + val firstItem = modules.flatMap { it.items }.firstOrNull() ?: return + val entity = HorizonDashboardModuleItemEntity( + moduleItemId = firstItem.id, + courseId = courseId, + moduleItemTitle = firstItem.title.orEmpty(), + moduleItemType = if (firstItem.quizLti) LearningObjectType.ASSESSMENT.name + else LearningObjectType.fromApiString(firstItem.type.orEmpty()).name, + dueDateMs = firstItem.moduleDetails?.dueDate?.time, + estimatedDuration = firstItem.estimatedDuration, + isQuizLti = firstItem.quizLti, + ) + moduleItemDao.insertAll(listOf(entity)) + } + + suspend fun getLastSyncedAt(): Long? { + return syncMetadataDao.getLastSyncedAt(HorizonSyncMetadataEntity.KEY_DASHBOARD_COURSES) + } + + private suspend fun updateLastSync() { + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + key = HorizonSyncMetadataEntity.KEY_DASHBOARD_COURSES, + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } +} 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..c128e39953 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 @@ -17,7 +17,6 @@ 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 @@ -29,7 +28,10 @@ import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidge 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.horizon.offline.HorizonOfflineViewModel import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus +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 @@ -43,8 +45,11 @@ import javax.inject.Inject class DashboardCourseViewModel @Inject constructor( @ApplicationContext private val context: Context, private val repository: DashboardCourseRepository, - private val dashboardEventHandler: DashboardEventHandler -): ViewModel() { + private val dashboardEventHandler: DashboardEventHandler, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { + 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,28 +96,26 @@ 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 } + private suspend fun fetchData() { + var enrollments = repository.getEnrollments() + val programs = repository.getPrograms() - // Accept invitations automatically + val invitations = enrollments.filter { it.state == EnrollmentWorkflowState.invited } if (invitations.isNotEmpty()) { invitations.forEach { enrollment -> repository.acceptInvite( enrollment.course?.id?.toLongOrNull() ?: return@forEach, - enrollment.id?.toLongOrNull() ?: return@forEach + enrollment.id?.toLongOrNull() ?: return@forEach, ) } - enrollments = repository.getEnrollments(true) + enrollments = repository.getEnrollments() } - val courseCardStates = enrollments.mapToDashboardCourseCardState( context, programs = programs, nextModuleForCourse = { courseId -> - fetchNextModuleState(courseId, forceNetwork) + fetchNextModuleState(courseId) }, ) @@ -118,26 +126,23 @@ class DashboardCourseViewModel @Inject constructor( _uiState.update { it.copy( programs = DashboardPaginatedWidgetCardState(programCardStates), - courses = courseCardStates + courses = courseCardStates, ) } } - private suspend fun fetchNextModuleState(courseId: Long?, forceNetwork: Boolean): DashboardCourseCardModuleItemState? { + private suspend fun fetchNextModuleState(courseId: Long?): 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 - } - + val modules = repository.getModuleItemsForCourse(courseId) + val nextModuleItem = modules.flatMap { it.items }.firstOrNull() ?: return null + val formattedDuration = nextModuleItem.estimatedDuration?.formatIsoDuration(context) return DashboardCourseCardModuleItemState( moduleItemTitle = nextModuleItem.title.orEmpty(), - moduleItemType = if (nextModuleItem.quizLti) LearningObjectType.ASSESSMENT else LearningObjectType.fromApiString(nextModuleItem.type.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) + estimatedDuration = formattedDuration, + onClickAction = CardClickAction.NavigateToModuleItem(courseId, nextModuleItem.id), ) } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonOfflineRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonOfflineRepository.kt new file mode 100644 index 0000000000..1f83d233b3 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonOfflineRepository.kt @@ -0,0 +1,31 @@ +/* + * 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 com.instructure.pandautils.repository.Repository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider + +abstract class HorizonOfflineRepository( + localDataSource: T, + networkDataSource: T, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider +): Repository(localDataSource, networkDataSource, networkStateProvider, featureFlagProvider) { + + suspend fun shouldSync(): Boolean = isOnline() && isOfflineEnabled() +} \ No newline at end of file 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..21260bd59f --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonOfflineViewModel.kt @@ -0,0 +1,49 @@ +/* + * 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.tryLaunch +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 +) : ViewModel() { + + abstract fun onNetworkRestored() + + abstract fun onNetworkLost() + + init { + viewModelScope.tryLaunch { + networkStateProvider.isOnlineLiveData.asFlow() + .distinctUntilChanged() + .drop(1) + .collect { isOnline -> + if (featureFlagProvider.offlineEnabled()) { + if (isOnline) onNetworkRestored() else onNetworkLost() + } + } + } + } +} \ No newline at end of file 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/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt index 14df17cf46..8d493122ff 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt @@ -53,7 +53,7 @@ class FeatureFlagProvider( } suspend fun offlineEnabled(): Boolean { - return checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary + return true//checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary } private suspend fun checkEnvironmentFeatureFlag(featureFlag: String): Boolean { From fb2b3f9bd9854be4b342201bc4cb9dc50b4c6ef6 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Mon, 30 Mar 2026 15:28:00 +0200 Subject: [PATCH 02/31] Migrate to usecase pattern --- .../CourseEnrollmentOfflineRepository.kt | 82 ++++++++++++ .../CourseEnrollmentOnlineRepository.kt} | 29 +---- .../repository/ModuleItemOfflineRepository.kt | 70 +++++++++++ .../repository/ModuleItemOnlineRepository.kt | 37 ++++++ .../repository/ProgramOfflineRepository.kt | 77 ++++++++++++ .../repository/ProgramOnlineRepository.kt} | 17 +-- .../usecase/AcceptCourseInviteUseCase.kt | 31 +++++ .../domain/usecase/GetEnrollmentsUseCase.kt | 48 +++++++ .../domain/usecase/GetModuleItemsUseCase.kt | 46 +++++++ .../domain/usecase/GetProgramsUseCase.kt | 48 +++++++ .../course/DashboardCourseLocalDataSource.kt | 117 ------------------ .../course/DashboardCourseRepository.kt | 78 ------------ .../widget/course/DashboardCourseSyncer.kt | 109 ---------------- .../widget/course/DashboardCourseViewModel.kt | 30 +++-- .../offline/HorizonOfflineRepository.kt | 31 ----- .../horizon/offline/OfflineSyncUseCase.kt | 32 +++++ 16 files changed, 504 insertions(+), 378 deletions(-) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentOfflineRepository.kt rename libs/horizon/src/main/java/com/instructure/horizon/{features/dashboard/widget/course/DashboardCourseNetworkDataSource.kt => data/repository/CourseEnrollmentOnlineRepository.kt} (54%) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemOfflineRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemOnlineRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramOfflineRepository.kt rename libs/horizon/src/main/java/com/instructure/horizon/{features/dashboard/widget/course/DashboardCourseDataSource.kt => data/repository/ProgramOnlineRepository.kt} (64%) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/AcceptCourseInviteUseCase.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetEnrollmentsUseCase.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModuleItemsUseCase.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetProgramsUseCase.kt delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseLocalDataSource.kt delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseRepository.kt delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseSyncer.kt delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonOfflineRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncUseCase.kt diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentOfflineRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentOfflineRepository.kt new file mode 100644 index 0000000000..8fe4149274 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentOfflineRepository.kt @@ -0,0 +1,82 @@ +/* + * 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.GetCoursesQuery +import com.instructure.canvasapi2.type.EnrollmentWorkflowState +import com.instructure.horizon.database.course.HorizonDashboardCourseDao +import com.instructure.horizon.database.course.HorizonDashboardCourseEntity +import com.instructure.horizon.database.sync.HorizonSyncMetadataDao +import com.instructure.horizon.database.sync.HorizonSyncMetadataEntity +import javax.inject.Inject + +class CourseEnrollmentOfflineRepository @Inject constructor( + private val courseDao: HorizonDashboardCourseDao, + private val syncMetadataDao: HorizonSyncMetadataDao, +) { + + suspend fun getEnrollments(): List { + return courseDao.getAll().map { entity -> + GetCoursesQuery.Enrollment( + id = entity.enrollmentId.toString(), + state = EnrollmentWorkflowState.safeValueOf(entity.enrollmentState), + lastActivityAt = null, + course = GetCoursesQuery.Course( + id = entity.courseId.toString(), + name = entity.courseName, + image_download_url = entity.courseImageUrl, + syllabus_body = null, + account = null, + usersConnection = GetCoursesQuery.UsersConnection( + nodes = listOf( + GetCoursesQuery.Node( + courseProgression = GetCoursesQuery.CourseProgression( + requirements = GetCoursesQuery.Requirements( + completionPercentage = entity.completionPercentage, + ), + incompleteModulesConnection = null, + ) + ) + ) + ), + ) + ) + } + } + + suspend fun saveEnrollments(enrollments: List) { + val entities = enrollments.mapNotNull { enrollment -> + val course = enrollment.course ?: return@mapNotNull null + val completionPercentage = course.usersConnection?.nodes + ?.firstOrNull()?.courseProgression?.requirements?.completionPercentage ?: 0.0 + HorizonDashboardCourseEntity( + enrollmentId = enrollment.id?.toLongOrNull() ?: return@mapNotNull null, + courseId = course.id.toLongOrNull() ?: return@mapNotNull null, + courseName = course.name, + courseImageUrl = course.image_download_url, + completionPercentage = completionPercentage, + enrollmentState = enrollment.state.rawValue, + ) + } + courseDao.replaceAll(entities) + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + key = HorizonSyncMetadataEntity.KEY_DASHBOARD_COURSES, + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentOnlineRepository.kt similarity index 54% rename from libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseNetworkDataSource.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentOnlineRepository.kt index 6fbe2a29b9..fb3859ae7b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseNetworkDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentOnlineRepository.kt @@ -13,46 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.features.dashboard.widget.course +package com.instructure.horizon.data.repository 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 DashboardCourseNetworkDataSource @Inject constructor( +class CourseEnrollmentOnlineRepository @Inject constructor( private val horizonGetCoursesManager: HorizonGetCoursesManager, - private val moduleApi: ModuleAPI.ModuleInterface, private val apiPrefs: ApiPrefs, private val enrollmentApi: EnrollmentAPI.EnrollmentInterface, - private val getProgramsManager: GetProgramsManager, -) : DashboardCourseDataSource { +) { - override suspend fun getEnrollments(): List { + suspend fun getEnrollments(): List { return horizonGetCoursesManager.getEnrollments(apiPrefs.user?.id ?: -1, forceNetwork = true).dataOrThrow } - override suspend fun getPrograms(): List { - return getProgramsManager.getPrograms(forceNetwork = true) - } - - override suspend fun getModuleItemsForCourse(courseId: Long): List { - val params = RestParams(isForceReadFromNetwork = true) - return moduleApi.getFirstPageModulesWithItems( - CanvasContext.Type.COURSE.apiString, - courseId, - params, - includes = listOf("estimated_durations") - ).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/repository/ModuleItemOfflineRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemOfflineRepository.kt new file mode 100644 index 0000000000..3eed5dbd92 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemOfflineRepository.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.ModuleItem +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemDao +import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemEntity +import com.instructure.horizon.model.LearningObjectType +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject + +class ModuleItemOfflineRepository @Inject constructor( + private val moduleItemDao: HorizonDashboardModuleItemDao, +) { + + suspend fun getModuleItemsForCourse(courseId: Long): List { + val entity = moduleItemDao.getFirstForCourse(courseId) ?: return emptyList() + val moduleItem = ModuleItem( + id = entity.moduleItemId, + moduleId = 0L, + title = entity.moduleItemTitle, + type = entity.moduleItemType, + quizLti = entity.isQuizLti, + estimatedDuration = entity.estimatedDuration, + moduleDetails = entity.dueDateMs?.let { ms -> + ModuleContentDetails(dueAt = isoFormatter.format(Date(ms))) + }, + ) + return listOf(ModuleObject(items = listOf(moduleItem))) + } + + suspend fun saveModuleItem(courseId: Long, modules: List) { + val firstItem = modules.flatMap { it.items }.firstOrNull() ?: return + val entity = HorizonDashboardModuleItemEntity( + moduleItemId = firstItem.id, + courseId = courseId, + moduleItemTitle = firstItem.title.orEmpty(), + moduleItemType = if (firstItem.quizLti) LearningObjectType.ASSESSMENT.name + else LearningObjectType.fromApiString(firstItem.type.orEmpty()).name, + dueDateMs = firstItem.moduleDetails?.dueDate?.time, + estimatedDuration = firstItem.estimatedDuration, + isQuizLti = firstItem.quizLti, + ) + moduleItemDao.insertAll(listOf(entity)) + } + + companion object { + private val isoFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+00:00", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemOnlineRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemOnlineRepository.kt new file mode 100644 index 0000000000..288f3d88c3 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemOnlineRepository.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.data.repository + +import com.instructure.canvasapi2.apis.ModuleAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.ModuleObject +import javax.inject.Inject + +class ModuleItemOnlineRepository @Inject constructor( + private val moduleApi: ModuleAPI.ModuleInterface, +) { + + suspend fun getModuleItemsForCourse(courseId: Long): List { + val params = RestParams(isForceReadFromNetwork = true) + return moduleApi.getFirstPageModulesWithItems( + CanvasContext.Type.COURSE.apiString, + courseId, + params, + includes = listOf("estimated_durations"), + ).dataOrThrow + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramOfflineRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramOfflineRepository.kt new file mode 100644 index 0000000000..7da396d280 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramOfflineRepository.kt @@ -0,0 +1,77 @@ +/* + * 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.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement +import com.instructure.horizon.database.course.HorizonDashboardCourseDao +import com.instructure.horizon.database.program.HorizonDashboardProgramCourseRef +import com.instructure.horizon.database.program.HorizonDashboardProgramDao +import com.instructure.horizon.database.program.HorizonDashboardProgramEntity +import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus +import com.instructure.journey.type.ProgramVariantType +import javax.inject.Inject + +class ProgramOfflineRepository @Inject constructor( + private val programDao: HorizonDashboardProgramDao, + private val courseDao: HorizonDashboardCourseDao, +) { + + suspend fun getPrograms(): List { + return programDao.getAll().map { programEntity -> + val refs = programDao.getRefsForProgram(programEntity.programId) + Program( + id = programEntity.programId, + name = programEntity.programName, + description = null, + startDate = null, + endDate = null, + variant = ProgramVariantType.UNKNOWN__, + sortedRequirements = refs.map { ref -> + ProgramRequirement( + id = "", + progressId = "", + courseId = ref.courseId, + required = false, + enrollmentStatus = ref.enrollmentStatus?.let { + ProgramProgressCourseEnrollmentStatus.safeValueOf(it) + }, + ) + } + ) + } + } + + suspend fun savePrograms(programs: List) { + val courseIds = courseDao.getAllCourseIds().toSet() + val programEntities = programs.map { HorizonDashboardProgramEntity(it.id, it.name) } + val refs = programs.flatMap { program -> + program.sortedRequirements + .filter { it.courseId in courseIds } + .map { req -> + HorizonDashboardProgramCourseRef( + programId = program.id, + courseId = req.courseId, + enrollmentStatus = req.enrollmentStatus?.rawValue, + ) + } + } + programDao.deleteAllRefs() + programDao.deleteAll() + programDao.insertAll(programEntities) + programDao.insertAllRefs(refs) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramOnlineRepository.kt similarity index 64% rename from libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseDataSource.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramOnlineRepository.kt index 64fbbac62d..a1ab998b6c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramOnlineRepository.kt @@ -13,14 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.features.dashboard.widget.course +package com.instructure.horizon.data.repository -import com.instructure.canvasapi2.GetCoursesQuery +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetProgramsManager import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program -import com.instructure.canvasapi2.models.ModuleObject +import javax.inject.Inject -interface DashboardCourseDataSource { - suspend fun getEnrollments(): List - suspend fun getPrograms(): List - suspend fun getModuleItemsForCourse(courseId: Long): List +class ProgramOnlineRepository @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/domain/usecase/AcceptCourseInviteUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/AcceptCourseInviteUseCase.kt new file mode 100644 index 0000000000..6f24ed72a6 --- /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.CourseEnrollmentOnlineRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +data class AcceptCourseInviteParams(val courseId: Long, val enrollmentId: Long) + +class AcceptCourseInviteUseCase @Inject constructor( + private val onlineRepository: CourseEnrollmentOnlineRepository, +) : BaseUseCase() { + + override suspend fun execute(params: AcceptCourseInviteParams) { + onlineRepository.acceptInvite(params.courseId, params.enrollmentId) + } +} 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..1bc92fa3d4 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetEnrollmentsUseCase.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.GetCoursesQuery +import com.instructure.horizon.data.repository.CourseEnrollmentOfflineRepository +import com.instructure.horizon.data.repository.CourseEnrollmentOnlineRepository +import com.instructure.horizon.offline.OfflineSyncUseCase +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class GetEnrollmentsUseCase @Inject constructor( + private val onlineRepository: CourseEnrollmentOnlineRepository, + private val offlineRepository: CourseEnrollmentOfflineRepository, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncUseCase>( + syncEnabled = true, + networkStateProvider = networkStateProvider, + featureFlagProvider = featureFlagProvider, +) { + + suspend operator fun invoke() = invoke(Unit) + + override suspend fun execute(params: Unit): List { + return if (shouldFetchFromNetwork()) { + onlineRepository.getEnrollments().also { + if (shouldSync()) offlineRepository.saveEnrollments(it) + } + } else { + offlineRepository.getEnrollments() + } + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModuleItemsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModuleItemsUseCase.kt new file mode 100644 index 0000000000..9933db7a48 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModuleItemsUseCase.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.data.repository.ModuleItemOfflineRepository +import com.instructure.horizon.data.repository.ModuleItemOnlineRepository +import com.instructure.horizon.offline.OfflineSyncUseCase +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class GetModuleItemsUseCase @Inject constructor( + private val onlineRepository: ModuleItemOnlineRepository, + private val offlineRepository: ModuleItemOfflineRepository, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncUseCase>( + syncEnabled = true, + networkStateProvider = networkStateProvider, + featureFlagProvider = featureFlagProvider, +) { + + override suspend fun execute(params: Long): List { + return if (shouldFetchFromNetwork()) { + onlineRepository.getModuleItemsForCourse(params).also { + if (shouldSync()) offlineRepository.saveModuleItem(params, it) + } + } else { + offlineRepository.getModuleItemsForCourse(params) + } + } +} 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..2729ce4cec --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetProgramsUseCase.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.horizon.data.repository.ProgramOfflineRepository +import com.instructure.horizon.data.repository.ProgramOnlineRepository +import com.instructure.horizon.offline.OfflineSyncUseCase +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class GetProgramsUseCase @Inject constructor( + private val onlineRepository: ProgramOnlineRepository, + private val offlineRepository: ProgramOfflineRepository, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncUseCase>( + syncEnabled = true, + networkStateProvider = networkStateProvider, + featureFlagProvider = featureFlagProvider, +) { + + suspend operator fun invoke() = invoke(Unit) + + override suspend fun execute(params: Unit): List { + return if (shouldFetchFromNetwork()) { + onlineRepository.getPrograms().also { + if (shouldSync()) offlineRepository.savePrograms(it) + } + } else { + offlineRepository.getPrograms() + } + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseLocalDataSource.kt deleted file mode 100644 index b0371ba8ec..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseLocalDataSource.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.instructure.horizon.features.dashboard.widget.course - -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.ModuleContentDetails -import com.instructure.canvasapi2.models.ModuleItem -import com.instructure.canvasapi2.models.ModuleObject -import com.instructure.canvasapi2.type.EnrollmentWorkflowState -import com.instructure.horizon.database.course.HorizonDashboardCourseDao -import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemDao -import com.instructure.horizon.database.program.HorizonDashboardProgramDao -import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import javax.inject.Inject - -class DashboardCourseLocalDataSource @Inject constructor( - private val courseDao: HorizonDashboardCourseDao, - private val programDao: HorizonDashboardProgramDao, - private val moduleItemDao: HorizonDashboardModuleItemDao, -) : DashboardCourseDataSource { - - override suspend fun getEnrollments(): List { - return courseDao.getAll().map { entity -> - GetCoursesQuery.Enrollment( - id = entity.enrollmentId.toString(), - state = EnrollmentWorkflowState.safeValueOf(entity.enrollmentState), - lastActivityAt = null, - course = GetCoursesQuery.Course( - id = entity.courseId.toString(), - name = entity.courseName, - image_download_url = entity.courseImageUrl, - syllabus_body = null, - account = null, - usersConnection = GetCoursesQuery.UsersConnection( - nodes = listOf( - GetCoursesQuery.Node( - courseProgression = GetCoursesQuery.CourseProgression( - requirements = GetCoursesQuery.Requirements( - completionPercentage = entity.completionPercentage, - ), - incompleteModulesConnection = null, - ) - ) - ) - ), - ) - ) - } - } - - override suspend fun getPrograms(): List { - val programs = programDao.getAll() - return programs.map { programEntity -> - val refs = programDao.getRefsForProgram(programEntity.programId) - Program( - id = programEntity.programId, - name = programEntity.programName, - description = null, - startDate = null, - endDate = null, - variant = com.instructure.journey.type.ProgramVariantType.UNKNOWN__, - sortedRequirements = refs.map { ref -> - ProgramRequirement( - id = "", - progressId = "", - courseId = ref.courseId, - required = false, - enrollmentStatus = ref.enrollmentStatus?.let { - ProgramProgressCourseEnrollmentStatus.safeValueOf(it) - }, - ) - } - ) - } - } - - override suspend fun getModuleItemsForCourse(courseId: Long): List { - val entity = moduleItemDao.getFirstForCourse(courseId) ?: return emptyList() - val moduleItem = ModuleItem( - id = entity.moduleItemId, - moduleId = 0L, - title = entity.moduleItemTitle, - type = entity.moduleItemType, - quizLti = entity.isQuizLti, - estimatedDuration = entity.estimatedDuration, - moduleDetails = entity.dueDateMs?.let { ms -> - ModuleContentDetails(dueAt = isoFormatter.format(Date(ms))) - }, - ) - return listOf(ModuleObject(items = listOf(moduleItem))) - } - - companion object { - private val isoFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+00:00", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - } -} 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 5edff1b821..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseRepository.kt +++ /dev/null @@ -1,78 +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.managers.graphql.horizon.journey.Program -import com.instructure.canvasapi2.models.ModuleObject -import com.instructure.horizon.offline.HorizonOfflineRepository -import com.instructure.horizon.offline.SyncPolicy -import com.instructure.pandautils.utils.FeatureFlagProvider -import com.instructure.pandautils.utils.NetworkStateProvider -import javax.inject.Inject - -class DashboardCourseRepository @Inject constructor( - private val networkDataSource: DashboardCourseNetworkDataSource, - private val localDataSource: DashboardCourseLocalDataSource, - private val localDataSync: DashboardCourseSyncer, - networkStateProvider: NetworkStateProvider, - featureFlagProvider: FeatureFlagProvider, -) : HorizonOfflineRepository( - localDataSource = localDataSource, - networkDataSource = networkDataSource, - networkStateProvider = networkStateProvider, - featureFlagProvider = featureFlagProvider, -) { - - suspend fun getEnrollments(): List { - return if (shouldFetchFromNetwork()) { - networkDataSource.getEnrollments().also { enrollments -> - if (isOfflineEnabled()) localDataSync.syncCourses(enrollments, SyncPolicy.ALWAYS_REPLACE) - } - } else { - localDataSource.getEnrollments() - } - } - - suspend fun acceptInvite(courseId: Long, enrollmentId: Long) { - networkDataSource.acceptInvite(courseId, enrollmentId) - } - - suspend fun getPrograms(): List { - return if (shouldFetchFromNetwork()) { - networkDataSource.getPrograms().also { programs -> - if (isOfflineEnabled()) localDataSync.syncPrograms(programs, SyncPolicy.ALWAYS_REPLACE) - } - } else { - localDataSource.getPrograms() - } - } - - suspend fun getModuleItemsForCourse(courseId: Long): List { - return if (shouldFetchFromNetwork()) { - networkDataSource.getModuleItemsForCourse(courseId).also { modules -> - if (isOfflineEnabled()) localDataSync.syncModuleItem(courseId, modules, SyncPolicy.ALWAYS_REPLACE) - } - } else { - localDataSource.getModuleItemsForCourse(courseId) - } - } - - suspend fun getLastSyncedAt(): Long? = localDataSync.getLastSyncedAt() - - private suspend fun shouldFetchFromNetwork() = isOnline() || !isOfflineEnabled() -} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseSyncer.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseSyncer.kt deleted file mode 100644 index 8955021cab..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseSyncer.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.instructure.horizon.features.dashboard.widget.course - -import com.instructure.canvasapi2.GetCoursesQuery -import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program -import com.instructure.canvasapi2.models.ModuleObject -import com.instructure.horizon.database.course.HorizonDashboardCourseDao -import com.instructure.horizon.database.course.HorizonDashboardCourseEntity -import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemDao -import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemEntity -import com.instructure.horizon.database.program.HorizonDashboardProgramCourseRef -import com.instructure.horizon.database.program.HorizonDashboardProgramDao -import com.instructure.horizon.database.program.HorizonDashboardProgramEntity -import com.instructure.horizon.database.sync.HorizonSyncMetadataDao -import com.instructure.horizon.database.sync.HorizonSyncMetadataEntity -import com.instructure.horizon.model.LearningObjectType -import com.instructure.horizon.offline.SyncPolicy -import javax.inject.Inject - -class DashboardCourseSyncer @Inject constructor( - private val courseDao: HorizonDashboardCourseDao, - private val programDao: HorizonDashboardProgramDao, - private val moduleItemDao: HorizonDashboardModuleItemDao, - private val syncMetadataDao: HorizonSyncMetadataDao, -) { - - suspend fun syncCourses(enrollments: List, policy: SyncPolicy) { - if (policy == SyncPolicy.SKIP_IF_PRESENT && courseDao.count() > 0) return - val entities = enrollments.mapNotNull { enrollment -> - val course = enrollment.course ?: return@mapNotNull null - val completionPercentage = course.usersConnection?.nodes - ?.firstOrNull()?.courseProgression?.requirements?.completionPercentage ?: 0.0 - HorizonDashboardCourseEntity( - enrollmentId = enrollment.id?.toLongOrNull() ?: return@mapNotNull null, - courseId = course.id.toLongOrNull() ?: return@mapNotNull null, - courseName = course.name, - courseImageUrl = course.image_download_url, - completionPercentage = completionPercentage, - enrollmentState = enrollment.state.rawValue, - ) - } - courseDao.replaceAll(entities) - if (policy == SyncPolicy.ALWAYS_REPLACE) updateLastSync() - } - - suspend fun syncPrograms(programs: List, policy: SyncPolicy) { - if (policy == SyncPolicy.SKIP_IF_PRESENT) return - val courseIds = courseDao.getAllCourseIds().toSet() - val programEntities = programs.map { HorizonDashboardProgramEntity(it.id, it.name) } - val refs = programs.flatMap { program -> - program.sortedRequirements - .filter { it.courseId in courseIds } - .map { req -> - HorizonDashboardProgramCourseRef( - programId = program.id, - courseId = req.courseId, - enrollmentStatus = req.enrollmentStatus?.rawValue, - ) - } - } - programDao.deleteAllRefs() - programDao.deleteAll() - programDao.insertAll(programEntities) - programDao.insertAllRefs(refs) - } - - suspend fun syncModuleItem(courseId: Long, modules: List, policy: SyncPolicy) { - if (policy == SyncPolicy.SKIP_IF_PRESENT) return - val firstItem = modules.flatMap { it.items }.firstOrNull() ?: return - val entity = HorizonDashboardModuleItemEntity( - moduleItemId = firstItem.id, - courseId = courseId, - moduleItemTitle = firstItem.title.orEmpty(), - moduleItemType = if (firstItem.quizLti) LearningObjectType.ASSESSMENT.name - else LearningObjectType.fromApiString(firstItem.type.orEmpty()).name, - dueDateMs = firstItem.moduleDetails?.dueDate?.time, - estimatedDuration = firstItem.estimatedDuration, - isQuizLti = firstItem.quizLti, - ) - moduleItemDao.insertAll(listOf(entity)) - } - - suspend fun getLastSyncedAt(): Long? { - return syncMetadataDao.getLastSyncedAt(HorizonSyncMetadataEntity.KEY_DASHBOARD_COURSES) - } - - private suspend fun updateLastSync() { - syncMetadataDao.upsert( - HorizonSyncMetadataEntity( - key = HorizonSyncMetadataEntity.KEY_DASHBOARD_COURSES, - lastSyncedAtMs = System.currentTimeMillis(), - ) - ) - } -} 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 c128e39953..696ee17704 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 @@ -21,6 +21,11 @@ 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.AcceptCourseInviteParams +import com.instructure.horizon.domain.usecase.AcceptCourseInviteUseCase +import com.instructure.horizon.domain.usecase.GetEnrollmentsUseCase +import com.instructure.horizon.domain.usecase.GetModuleItemsUseCase +import com.instructure.horizon.domain.usecase.GetProgramsUseCase import com.instructure.horizon.features.dashboard.DashboardEvent import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.horizon.features.dashboard.DashboardItemState @@ -44,7 +49,10 @@ import javax.inject.Inject @HiltViewModel class DashboardCourseViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val repository: DashboardCourseRepository, + private val getEnrollmentsUseCase: GetEnrollmentsUseCase, + private val getProgramsUseCase: GetProgramsUseCase, + private val getModuleItemsUseCase: GetModuleItemsUseCase, + private val acceptCourseInviteUseCase: AcceptCourseInviteUseCase, private val dashboardEventHandler: DashboardEventHandler, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, @@ -97,26 +105,26 @@ class DashboardCourseViewModel @Inject constructor( } private suspend fun fetchData() { - var enrollments = repository.getEnrollments() - val programs = repository.getPrograms() + var enrollments = getEnrollmentsUseCase() + val programs = getProgramsUseCase() val invitations = enrollments.filter { it.state == EnrollmentWorkflowState.invited } if (invitations.isNotEmpty()) { invitations.forEach { enrollment -> - repository.acceptInvite( - enrollment.course?.id?.toLongOrNull() ?: return@forEach, - enrollment.id?.toLongOrNull() ?: return@forEach, + acceptCourseInviteUseCase( + AcceptCourseInviteParams( + courseId = enrollment.course?.id?.toLongOrNull() ?: return@forEach, + enrollmentId = enrollment.id?.toLongOrNull() ?: return@forEach, + ) ) } - enrollments = repository.getEnrollments() + enrollments = getEnrollmentsUseCase() } val courseCardStates = enrollments.mapToDashboardCourseCardState( context, programs = programs, - nextModuleForCourse = { courseId -> - fetchNextModuleState(courseId) - }, + nextModuleForCourse = { courseId -> fetchNextModuleState(courseId) }, ) val programCardStates = programs @@ -133,7 +141,7 @@ class DashboardCourseViewModel @Inject constructor( private suspend fun fetchNextModuleState(courseId: Long?): DashboardCourseCardModuleItemState? { if (courseId == null) return null - val modules = repository.getModuleItemsForCourse(courseId) + val modules = getModuleItemsUseCase(courseId) val nextModuleItem = modules.flatMap { it.items }.firstOrNull() ?: return null val formattedDuration = nextModuleItem.estimatedDuration?.formatIsoDuration(context) return DashboardCourseCardModuleItemState( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonOfflineRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonOfflineRepository.kt deleted file mode 100644 index 1f83d233b3..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonOfflineRepository.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.offline - -import com.instructure.pandautils.repository.Repository -import com.instructure.pandautils.utils.FeatureFlagProvider -import com.instructure.pandautils.utils.NetworkStateProvider - -abstract class HorizonOfflineRepository( - localDataSource: T, - networkDataSource: T, - networkStateProvider: NetworkStateProvider, - featureFlagProvider: FeatureFlagProvider -): Repository(localDataSource, networkDataSource, networkStateProvider, featureFlagProvider) { - - suspend fun shouldSync(): Boolean = isOnline() && isOfflineEnabled() -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncUseCase.kt new file mode 100644 index 0000000000..c36f425a36 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncUseCase.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.offline + +import com.instructure.pandautils.domain.usecase.BaseUseCase +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider + +abstract class OfflineSyncUseCase( + val syncEnabled: Boolean, + private val networkStateProvider: NetworkStateProvider, + private val featureFlagProvider: FeatureFlagProvider, +) : BaseUseCase() { + + fun isOnline() = networkStateProvider.isOnline() + suspend fun offlineEnabled() = featureFlagProvider.offlineEnabled() + suspend fun shouldFetchFromNetwork() = isOnline() || !offlineEnabled() + suspend fun shouldSync() = syncEnabled && isOnline() && offlineEnabled() +} From 1951eeacc815ac2102909f4df48100ab6ecf5a5e Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Tue, 31 Mar 2026 15:27:29 +0200 Subject: [PATCH 03/31] Refactor architecture --- .../datasource/CourseEnrollmentDataSource.kt | 22 +++++++++ .../CourseEnrollmentLocalDataSource.kt} | 8 ++-- .../CourseEnrollmentNetworkDataSource.kt} | 8 ++-- .../data/datasource/ModuleItemDataSource.kt | 22 +++++++++ .../ModuleItemLocalDataSource.kt} | 8 ++-- .../ModuleItemNetworkDataSource.kt} | 8 ++-- .../data/datasource/ProgramDataSource.kt | 22 +++++++++ .../ProgramLocalDataSource.kt} | 8 ++-- .../ProgramNetworkDataSource.kt} | 8 ++-- .../repository/CourseEnrollmentRepository.kt | 45 +++++++++++++++++++ .../data/repository/ModuleItemRepository.kt | 41 +++++++++++++++++ .../data/repository/ProgramRepository.kt | 41 +++++++++++++++++ .../usecase/AcceptCourseInviteUseCase.kt | 6 +-- .../domain/usecase/GetEnrollmentsUseCase.kt | 28 +++--------- .../domain/usecase/GetModuleItemsUseCase.kt | 28 +++--------- .../domain/usecase/GetProgramsUseCase.kt | 28 +++--------- ...yncUseCase.kt => OfflineSyncRepository.kt} | 9 ++-- 17 files changed, 238 insertions(+), 102 deletions(-) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentDataSource.kt rename libs/horizon/src/main/java/com/instructure/horizon/data/{repository/CourseEnrollmentOfflineRepository.kt => datasource/CourseEnrollmentLocalDataSource.kt} (94%) rename libs/horizon/src/main/java/com/instructure/horizon/data/{repository/CourseEnrollmentOnlineRepository.kt => datasource/CourseEnrollmentNetworkDataSource.kt} (86%) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemDataSource.kt rename libs/horizon/src/main/java/com/instructure/horizon/data/{repository/ModuleItemOfflineRepository.kt => datasource/ModuleItemLocalDataSource.kt} (92%) rename libs/horizon/src/main/java/com/instructure/horizon/data/{repository/ModuleItemOnlineRepository.kt => datasource/ModuleItemNetworkDataSource.kt} (85%) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDataSource.kt rename libs/horizon/src/main/java/com/instructure/horizon/data/{repository/ProgramOfflineRepository.kt => datasource/ProgramLocalDataSource.kt} (94%) rename libs/horizon/src/main/java/com/instructure/horizon/data/{repository/ProgramOnlineRepository.kt => datasource/ProgramNetworkDataSource.kt} (84%) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt rename libs/horizon/src/main/java/com/instructure/horizon/offline/{OfflineSyncUseCase.kt => OfflineSyncRepository.kt} (81%) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentDataSource.kt new file mode 100644 index 0000000000..bde0d5fc26 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentDataSource.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.GetCoursesQuery + +interface CourseEnrollmentDataSource { + suspend fun getEnrollments(): List +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentOfflineRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentLocalDataSource.kt similarity index 94% rename from libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentOfflineRepository.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentLocalDataSource.kt index 8fe4149274..aaead42b9a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentOfflineRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentLocalDataSource.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.data.repository +package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.GetCoursesQuery import com.instructure.canvasapi2.type.EnrollmentWorkflowState @@ -23,12 +23,12 @@ import com.instructure.horizon.database.sync.HorizonSyncMetadataDao import com.instructure.horizon.database.sync.HorizonSyncMetadataEntity import javax.inject.Inject -class CourseEnrollmentOfflineRepository @Inject constructor( +class CourseEnrollmentLocalDataSource @Inject constructor( private val courseDao: HorizonDashboardCourseDao, private val syncMetadataDao: HorizonSyncMetadataDao, -) { +) : CourseEnrollmentDataSource { - suspend fun getEnrollments(): List { + override suspend fun getEnrollments(): List { return courseDao.getAll().map { entity -> GetCoursesQuery.Enrollment( id = entity.enrollmentId.toString(), diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentOnlineRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt similarity index 86% rename from libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentOnlineRepository.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt index fb3859ae7b..80a4aac918 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentOnlineRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.data.repository +package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.GetCoursesQuery import com.instructure.canvasapi2.apis.EnrollmentAPI @@ -22,13 +22,13 @@ import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesMana import com.instructure.canvasapi2.utils.ApiPrefs import javax.inject.Inject -class CourseEnrollmentOnlineRepository @Inject constructor( +class CourseEnrollmentNetworkDataSource @Inject constructor( private val horizonGetCoursesManager: HorizonGetCoursesManager, private val apiPrefs: ApiPrefs, private val enrollmentApi: EnrollmentAPI.EnrollmentInterface, -) { +) : CourseEnrollmentDataSource { - suspend fun getEnrollments(): List { + override suspend fun getEnrollments(): List { return horizonGetCoursesManager.getEnrollments(apiPrefs.user?.id ?: -1, forceNetwork = true).dataOrThrow } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemDataSource.kt new file mode 100644 index 0000000000..b3ed52cee3 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemDataSource.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.models.ModuleObject + +interface ModuleItemDataSource { + suspend fun getModuleItemsForCourse(courseId: Long): List +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemOfflineRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemLocalDataSource.kt similarity index 92% rename from libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemOfflineRepository.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemLocalDataSource.kt index 3eed5dbd92..66eee8e532 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemOfflineRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemLocalDataSource.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.data.repository +package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem @@ -27,11 +27,11 @@ import java.util.Locale import java.util.TimeZone import javax.inject.Inject -class ModuleItemOfflineRepository @Inject constructor( +class ModuleItemLocalDataSource @Inject constructor( private val moduleItemDao: HorizonDashboardModuleItemDao, -) { +) : ModuleItemDataSource { - suspend fun getModuleItemsForCourse(courseId: Long): List { + override suspend fun getModuleItemsForCourse(courseId: Long): List { val entity = moduleItemDao.getFirstForCourse(courseId) ?: return emptyList() val moduleItem = ModuleItem( id = entity.moduleItemId, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemOnlineRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt similarity index 85% rename from libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemOnlineRepository.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt index 288f3d88c3..06fb77e4f4 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemOnlineRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.data.repository +package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.apis.ModuleAPI import com.instructure.canvasapi2.builders.RestParams @@ -21,11 +21,11 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.ModuleObject import javax.inject.Inject -class ModuleItemOnlineRepository @Inject constructor( +class ModuleItemNetworkDataSource @Inject constructor( private val moduleApi: ModuleAPI.ModuleInterface, -) { +) : ModuleItemDataSource { - suspend fun getModuleItemsForCourse(courseId: Long): List { + override suspend fun getModuleItemsForCourse(courseId: Long): List { val params = RestParams(isForceReadFromNetwork = true) return moduleApi.getFirstPageModulesWithItems( CanvasContext.Type.COURSE.apiString, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDataSource.kt new file mode 100644 index 0000000000..291e062930 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDataSource.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program + +interface ProgramDataSource { + suspend fun getPrograms(): List +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramOfflineRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt similarity index 94% rename from libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramOfflineRepository.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt index 7da396d280..678f3b0434 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramOfflineRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.data.repository +package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement @@ -25,12 +25,12 @@ import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus import com.instructure.journey.type.ProgramVariantType import javax.inject.Inject -class ProgramOfflineRepository @Inject constructor( +class ProgramLocalDataSource @Inject constructor( private val programDao: HorizonDashboardProgramDao, private val courseDao: HorizonDashboardCourseDao, -) { +) : ProgramDataSource { - suspend fun getPrograms(): List { + override suspend fun getPrograms(): List { return programDao.getAll().map { programEntity -> val refs = programDao.getRefsForProgram(programEntity.programId) Program( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramOnlineRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt similarity index 84% rename from libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramOnlineRepository.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt index a1ab998b6c..c7fab933e1 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramOnlineRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt @@ -13,17 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.data.repository +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 ProgramOnlineRepository @Inject constructor( +class ProgramNetworkDataSource @Inject constructor( private val getProgramsManager: GetProgramsManager, -) { +) : ProgramDataSource { - suspend fun getPrograms(): List { + override 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..1c51a4e1e0 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.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.canvasapi2.GetCoursesQuery +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 acceptInvite(courseId: Long, enrollmentId: Long) { + networkDataSource.acceptInvite(courseId, enrollmentId) + } +} 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..b5c5702a0e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.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.repository + +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.data.datasource.ModuleItemLocalDataSource +import com.instructure.horizon.data.datasource.ModuleItemNetworkDataSource +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 getModuleItemsForCourse(courseId: Long): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getModuleItemsForCourse(courseId) + .also { if (shouldSync()) localDataSource.saveModuleItem(courseId, it) } + } else { + localDataSource.getModuleItemsForCourse(courseId) + } + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt new file mode 100644 index 0000000000..42846153b4 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.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.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, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getPrograms(): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getPrograms() + .also { if (shouldSync()) localDataSource.savePrograms(it) } + } else { + localDataSource.getPrograms() + } + } +} 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 index 6f24ed72a6..b2dcf192a9 100644 --- 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 @@ -15,17 +15,17 @@ */ package com.instructure.horizon.domain.usecase -import com.instructure.horizon.data.repository.CourseEnrollmentOnlineRepository +import com.instructure.horizon.data.repository.CourseEnrollmentRepository import com.instructure.pandautils.domain.usecase.BaseUseCase import javax.inject.Inject data class AcceptCourseInviteParams(val courseId: Long, val enrollmentId: Long) class AcceptCourseInviteUseCase @Inject constructor( - private val onlineRepository: CourseEnrollmentOnlineRepository, + private val repository: CourseEnrollmentRepository, ) : BaseUseCase() { override suspend fun execute(params: AcceptCourseInviteParams) { - onlineRepository.acceptInvite(params.courseId, params.enrollmentId) + repository.acceptInvite(params.courseId, params.enrollmentId) } } 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 index 1bc92fa3d4..e43f61b54a 100644 --- 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 @@ -16,33 +16,15 @@ package com.instructure.horizon.domain.usecase import com.instructure.canvasapi2.GetCoursesQuery -import com.instructure.horizon.data.repository.CourseEnrollmentOfflineRepository -import com.instructure.horizon.data.repository.CourseEnrollmentOnlineRepository -import com.instructure.horizon.offline.OfflineSyncUseCase -import com.instructure.pandautils.utils.FeatureFlagProvider -import com.instructure.pandautils.utils.NetworkStateProvider +import com.instructure.horizon.data.repository.CourseEnrollmentRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase import javax.inject.Inject class GetEnrollmentsUseCase @Inject constructor( - private val onlineRepository: CourseEnrollmentOnlineRepository, - private val offlineRepository: CourseEnrollmentOfflineRepository, - networkStateProvider: NetworkStateProvider, - featureFlagProvider: FeatureFlagProvider, -) : OfflineSyncUseCase>( - syncEnabled = true, - networkStateProvider = networkStateProvider, - featureFlagProvider = featureFlagProvider, -) { + private val repository: CourseEnrollmentRepository, +) : BaseUseCase>() { suspend operator fun invoke() = invoke(Unit) - override suspend fun execute(params: Unit): List { - return if (shouldFetchFromNetwork()) { - onlineRepository.getEnrollments().also { - if (shouldSync()) offlineRepository.saveEnrollments(it) - } - } else { - offlineRepository.getEnrollments() - } - } + override suspend fun execute(params: Unit) = repository.getEnrollments() } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModuleItemsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModuleItemsUseCase.kt index 9933db7a48..960446ebc4 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModuleItemsUseCase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModuleItemsUseCase.kt @@ -16,31 +16,13 @@ package com.instructure.horizon.domain.usecase import com.instructure.canvasapi2.models.ModuleObject -import com.instructure.horizon.data.repository.ModuleItemOfflineRepository -import com.instructure.horizon.data.repository.ModuleItemOnlineRepository -import com.instructure.horizon.offline.OfflineSyncUseCase -import com.instructure.pandautils.utils.FeatureFlagProvider -import com.instructure.pandautils.utils.NetworkStateProvider +import com.instructure.horizon.data.repository.ModuleItemRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase import javax.inject.Inject class GetModuleItemsUseCase @Inject constructor( - private val onlineRepository: ModuleItemOnlineRepository, - private val offlineRepository: ModuleItemOfflineRepository, - networkStateProvider: NetworkStateProvider, - featureFlagProvider: FeatureFlagProvider, -) : OfflineSyncUseCase>( - syncEnabled = true, - networkStateProvider = networkStateProvider, - featureFlagProvider = featureFlagProvider, -) { + private val repository: ModuleItemRepository, +) : BaseUseCase>() { - override suspend fun execute(params: Long): List { - return if (shouldFetchFromNetwork()) { - onlineRepository.getModuleItemsForCourse(params).also { - if (shouldSync()) offlineRepository.saveModuleItem(params, it) - } - } else { - offlineRepository.getModuleItemsForCourse(params) - } - } + override suspend fun execute(params: Long) = repository.getModuleItemsForCourse(params) } 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 index 2729ce4cec..22d1191bd4 100644 --- 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 @@ -16,33 +16,15 @@ package com.instructure.horizon.domain.usecase import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program -import com.instructure.horizon.data.repository.ProgramOfflineRepository -import com.instructure.horizon.data.repository.ProgramOnlineRepository -import com.instructure.horizon.offline.OfflineSyncUseCase -import com.instructure.pandautils.utils.FeatureFlagProvider -import com.instructure.pandautils.utils.NetworkStateProvider +import com.instructure.horizon.data.repository.ProgramRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase import javax.inject.Inject class GetProgramsUseCase @Inject constructor( - private val onlineRepository: ProgramOnlineRepository, - private val offlineRepository: ProgramOfflineRepository, - networkStateProvider: NetworkStateProvider, - featureFlagProvider: FeatureFlagProvider, -) : OfflineSyncUseCase>( - syncEnabled = true, - networkStateProvider = networkStateProvider, - featureFlagProvider = featureFlagProvider, -) { + private val repository: ProgramRepository, +) : BaseUseCase>() { suspend operator fun invoke() = invoke(Unit) - override suspend fun execute(params: Unit): List { - return if (shouldFetchFromNetwork()) { - onlineRepository.getPrograms().also { - if (shouldSync()) offlineRepository.savePrograms(it) - } - } else { - offlineRepository.getPrograms() - } - } + override suspend fun execute(params: Unit) = repository.getPrograms() } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncRepository.kt similarity index 81% rename from libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncUseCase.kt rename to libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncRepository.kt index c36f425a36..6377c5c817 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncUseCase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncRepository.kt @@ -15,18 +15,15 @@ */ package com.instructure.horizon.offline -import com.instructure.pandautils.domain.usecase.BaseUseCase import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.NetworkStateProvider -abstract class OfflineSyncUseCase( - val syncEnabled: Boolean, +abstract class OfflineSyncRepository( private val networkStateProvider: NetworkStateProvider, private val featureFlagProvider: FeatureFlagProvider, -) : BaseUseCase() { - +) { fun isOnline() = networkStateProvider.isOnline() suspend fun offlineEnabled() = featureFlagProvider.offlineEnabled() suspend fun shouldFetchFromNetwork() = isOnline() || !offlineEnabled() - suspend fun shouldSync() = syncEnabled && isOnline() && offlineEnabled() + suspend fun shouldSync() = isOnline() && offlineEnabled() } From 1072fe3e990922fc17f372bfb8b56ee3a2ce7506 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Tue, 31 Mar 2026 16:03:22 +0200 Subject: [PATCH 04/31] Improve architecture --- .../datasource/CourseEnrollmentDataSource.kt | 22 ------ .../CourseEnrollmentLocalDataSource.kt | 4 +- .../CourseEnrollmentNetworkDataSource.kt | 4 +- .../data/datasource/ModuleItemDataSource.kt | 22 ------ .../datasource/ModuleItemLocalDataSource.kt | 4 +- .../datasource/ModuleItemNetworkDataSource.kt | 4 +- .../data/datasource/ProgramDataSource.kt | 22 ------ .../data/datasource/ProgramLocalDataSource.kt | 4 +- .../datasource/ProgramNetworkDataSource.kt | 4 +- .../usecase/GetDashboardCoursesUseCase.kt | 70 +++++++++++++++++ .../domain/usecase/GetProgramsUseCase.kt | 1 - .../widget/course/DashboardCourseViewModel.kt | 78 +++++++------------ 12 files changed, 109 insertions(+), 130 deletions(-) delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentDataSource.kt delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemDataSource.kt delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetDashboardCoursesUseCase.kt diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentDataSource.kt deleted file mode 100644 index bde0d5fc26..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentDataSource.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.instructure.horizon.data.datasource - -import com.instructure.canvasapi2.GetCoursesQuery - -interface CourseEnrollmentDataSource { - suspend fun getEnrollments(): List -} 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 index aaead42b9a..71208a6fc8 100644 --- 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 @@ -26,9 +26,9 @@ import javax.inject.Inject class CourseEnrollmentLocalDataSource @Inject constructor( private val courseDao: HorizonDashboardCourseDao, private val syncMetadataDao: HorizonSyncMetadataDao, -) : CourseEnrollmentDataSource { +) { - override suspend fun getEnrollments(): List { + suspend fun getEnrollments(): List { return courseDao.getAll().map { entity -> GetCoursesQuery.Enrollment( id = entity.enrollmentId.toString(), diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt index 80a4aac918..35c82f2dfe 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt @@ -26,9 +26,9 @@ class CourseEnrollmentNetworkDataSource @Inject constructor( private val horizonGetCoursesManager: HorizonGetCoursesManager, private val apiPrefs: ApiPrefs, private val enrollmentApi: EnrollmentAPI.EnrollmentInterface, -) : CourseEnrollmentDataSource { +) { - override suspend fun getEnrollments(): List { + suspend fun getEnrollments(): List { return horizonGetCoursesManager.getEnrollments(apiPrefs.user?.id ?: -1, forceNetwork = true).dataOrThrow } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemDataSource.kt deleted file mode 100644 index b3ed52cee3..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemDataSource.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.instructure.horizon.data.datasource - -import com.instructure.canvasapi2.models.ModuleObject - -interface ModuleItemDataSource { - suspend fun getModuleItemsForCourse(courseId: Long): List -} 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 index 66eee8e532..bdf8aa6eb5 100644 --- 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 @@ -29,9 +29,9 @@ import javax.inject.Inject class ModuleItemLocalDataSource @Inject constructor( private val moduleItemDao: HorizonDashboardModuleItemDao, -) : ModuleItemDataSource { +) { - override suspend fun getModuleItemsForCourse(courseId: Long): List { + suspend fun getModuleItemsForCourse(courseId: Long): List { val entity = moduleItemDao.getFirstForCourse(courseId) ?: return emptyList() val moduleItem = ModuleItem( id = entity.moduleItemId, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt index 06fb77e4f4..a3a4939c3a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt @@ -23,9 +23,9 @@ import javax.inject.Inject class ModuleItemNetworkDataSource @Inject constructor( private val moduleApi: ModuleAPI.ModuleInterface, -) : ModuleItemDataSource { +) { - override suspend fun getModuleItemsForCourse(courseId: Long): List { + suspend fun getModuleItemsForCourse(courseId: Long): List { val params = RestParams(isForceReadFromNetwork = true) return moduleApi.getFirstPageModulesWithItems( CanvasContext.Type.COURSE.apiString, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDataSource.kt deleted file mode 100644 index 291e062930..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDataSource.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.instructure.horizon.data.datasource - -import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program - -interface ProgramDataSource { - suspend fun getPrograms(): List -} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt index 678f3b0434..e8a13f063a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt @@ -28,9 +28,9 @@ import javax.inject.Inject class ProgramLocalDataSource @Inject constructor( private val programDao: HorizonDashboardProgramDao, private val courseDao: HorizonDashboardCourseDao, -) : ProgramDataSource { +) { - override suspend fun getPrograms(): List { + suspend fun getPrograms(): List { return programDao.getAll().map { programEntity -> val refs = programDao.getRefsForProgram(programEntity.programId) Program( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt index c7fab933e1..aaea7ad793 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt @@ -21,9 +21,9 @@ import javax.inject.Inject class ProgramNetworkDataSource @Inject constructor( private val getProgramsManager: GetProgramsManager, -) : ProgramDataSource { +) { - override suspend fun getPrograms(): List { + suspend fun getPrograms(): List { return getProgramsManager.getPrograms(forceNetwork = true) } } 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..a841ac4610 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetDashboardCoursesUseCase.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.GetCoursesQuery +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.models.ModuleItem +import com.instructure.canvasapi2.type.EnrollmentWorkflowState +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +data class DashboardCoursesData( + val enrollments: List, + val programs: List, + val nextModuleItemByCourseId: Map, +) + +class GetDashboardCoursesUseCase @Inject constructor( + private val getEnrollmentsUseCase: GetEnrollmentsUseCase, + private val getProgramsUseCase: GetProgramsUseCase, + private val getModuleItemsUseCase: GetModuleItemsUseCase, + 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.state == EnrollmentWorkflowState.invited } + if (invitations.isNotEmpty()) { + invitations.forEach { enrollment -> + acceptCourseInviteUseCase( + AcceptCourseInviteParams( + courseId = enrollment.course?.id?.toLongOrNull() ?: return@forEach, + enrollmentId = enrollment.id?.toLongOrNull() ?: return@forEach, + ) + ) + } + enrollments = getEnrollmentsUseCase() + } + + val nextModuleItemByCourseId = enrollments + .filter { it.state == EnrollmentWorkflowState.active } + .mapNotNull { it.course?.id?.toLongOrNull() } + .associate { courseId -> + courseId to getModuleItemsUseCase(courseId).flatMap { it.items }.firstOrNull() + } + + return DashboardCoursesData( + enrollments = enrollments, + programs = programs, + nextModuleItemByCourseId = nextModuleItemByCourseId, + ) + } +} \ No newline at end of file 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 index 22d1191bd4..e59c058be9 100644 --- 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 @@ -23,7 +23,6 @@ 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/widget/course/DashboardCourseViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseViewModel.kt index 696ee17704..672f33fd2e 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,31 +1,26 @@ /* * 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.viewModelScope -import com.instructure.canvasapi2.type.EnrollmentWorkflowState +import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch -import com.instructure.horizon.domain.usecase.AcceptCourseInviteParams -import com.instructure.horizon.domain.usecase.AcceptCourseInviteUseCase -import com.instructure.horizon.domain.usecase.GetEnrollmentsUseCase -import com.instructure.horizon.domain.usecase.GetModuleItemsUseCase -import com.instructure.horizon.domain.usecase.GetProgramsUseCase +import com.instructure.horizon.domain.usecase.GetDashboardCoursesUseCase import com.instructure.horizon.features.dashboard.DashboardEvent import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.horizon.features.dashboard.DashboardItemState @@ -49,10 +44,7 @@ import javax.inject.Inject @HiltViewModel class DashboardCourseViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val getEnrollmentsUseCase: GetEnrollmentsUseCase, - private val getProgramsUseCase: GetProgramsUseCase, - private val getModuleItemsUseCase: GetModuleItemsUseCase, - private val acceptCourseInviteUseCase: AcceptCourseInviteUseCase, + private val getDashboardCoursesUseCase: GetDashboardCoursesUseCase, private val dashboardEventHandler: DashboardEventHandler, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, @@ -105,29 +97,17 @@ class DashboardCourseViewModel @Inject constructor( } private suspend fun fetchData() { - var enrollments = getEnrollmentsUseCase() - val programs = getProgramsUseCase() - - val invitations = enrollments.filter { it.state == EnrollmentWorkflowState.invited } - if (invitations.isNotEmpty()) { - invitations.forEach { enrollment -> - acceptCourseInviteUseCase( - AcceptCourseInviteParams( - courseId = enrollment.course?.id?.toLongOrNull() ?: return@forEach, - enrollmentId = enrollment.id?.toLongOrNull() ?: return@forEach, - ) - ) - } - enrollments = getEnrollmentsUseCase() - } + val data = getDashboardCoursesUseCase() - val courseCardStates = enrollments.mapToDashboardCourseCardState( + val courseCardStates = data.enrollments.mapToDashboardCourseCardState( context, - programs = programs, - nextModuleForCourse = { courseId -> fetchNextModuleState(courseId) }, + programs = data.programs, + nextModuleForCourse = { courseId -> + courseId?.let { data.nextModuleItemByCourseId[it] }?.let { mapToModuleItemState(courseId, it) } + }, ) - val programCardStates = programs + val programCardStates = data.programs .filter { program -> program.sortedRequirements.none { it.enrollmentStatus == ProgramProgressCourseEnrollmentStatus.ENROLLED } } .mapToDashboardCourseCardState(context) @@ -139,18 +119,14 @@ class DashboardCourseViewModel @Inject constructor( } } - private suspend fun fetchNextModuleState(courseId: Long?): DashboardCourseCardModuleItemState? { - if (courseId == null) return null - val modules = getModuleItemsUseCase(courseId) - val nextModuleItem = modules.flatMap { it.items }.firstOrNull() ?: return null - val formattedDuration = nextModuleItem.estimatedDuration?.formatIsoDuration(context) + private fun mapToModuleItemState(courseId: Long, moduleItem: ModuleItem): DashboardCourseCardModuleItemState { return DashboardCourseCardModuleItemState( - moduleItemTitle = nextModuleItem.title.orEmpty(), - moduleItemType = if (nextModuleItem.quizLti) LearningObjectType.ASSESSMENT - else LearningObjectType.fromApiString(nextModuleItem.type.orEmpty()), - dueDate = nextModuleItem.moduleDetails?.dueDate, - estimatedDuration = formattedDuration, - onClickAction = CardClickAction.NavigateToModuleItem(courseId, nextModuleItem.id), + moduleItemTitle = moduleItem.title.orEmpty(), + moduleItemType = if (moduleItem.quizLti) LearningObjectType.ASSESSMENT + else LearningObjectType.fromApiString(moduleItem.type.orEmpty()), + dueDate = moduleItem.moduleDetails?.dueDate, + estimatedDuration = moduleItem.estimatedDuration?.formatIsoDuration(context), + onClickAction = CardClickAction.NavigateToModuleItem(courseId, moduleItem.id), ) } -} +} \ No newline at end of file From df03097a5555161e3ed32468db3d44d2d8d81ca8 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 1 Apr 2026 10:42:08 +0200 Subject: [PATCH 05/31] Add sync functionality --- .../horizon/data/repository/CourseEnrollmentRepository.kt | 4 ++++ .../horizon/data/repository/ModuleItemRepository.kt | 4 ++++ .../instructure/horizon/data/repository/ProgramRepository.kt | 4 ++++ .../com/instructure/horizon/offline/OfflineSyncRepository.kt | 2 ++ 4 files changed, 14 insertions(+) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt index 1c51a4e1e0..2fb2b8730c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt @@ -42,4 +42,8 @@ class CourseEnrollmentRepository @Inject constructor( 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 index b5c5702a0e..a6b06d39de 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt @@ -38,4 +38,8 @@ class ModuleItemRepository @Inject constructor( localDataSource.getModuleItemsForCourse(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 index 42846153b4..be3e618ca2 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt @@ -38,4 +38,8 @@ class ProgramRepository @Inject constructor( localDataSource.getPrograms() } } + + override suspend fun sync() { + TODO("Not yet implemented") + } } 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 index 6377c5c817..eccd1754a6 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncRepository.kt @@ -26,4 +26,6 @@ abstract class OfflineSyncRepository( suspend fun offlineEnabled() = featureFlagProvider.offlineEnabled() suspend fun shouldFetchFromNetwork() = isOnline() || !offlineEnabled() suspend fun shouldSync() = isOnline() && offlineEnabled() + + abstract suspend fun sync() } From 544244c191ac7458302c2fb2dfe08f6fe5df0afa Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 1 Apr 2026 12:09:35 +0200 Subject: [PATCH 06/31] Refactor daos --- .../CourseEnrollmentLocalDataSource.kt | 8 ++++---- .../datasource/ModuleItemLocalDataSource.kt | 4 ++-- .../data/datasource/ProgramLocalDataSource.kt | 8 ++++---- .../horizon/database/HorizonDatabase.kt | 18 +++++++++--------- .../HorizonDashboardCourseDao.kt | 3 ++- .../HorizonDashboardModuleItemDao.kt | 3 ++- .../HorizonDashboardProgramDao.kt | 4 +++- .../{sync => dao}/HorizonSyncMetadataDao.kt | 3 ++- .../HorizonDashboardCourseEntity.kt | 2 +- .../HorizonDashboardModuleItemEntity.kt | 2 +- .../HorizonDashboardProgramCourseRef.kt | 2 +- .../HorizonDashboardProgramEntity.kt | 2 +- .../HorizonSyncMetadataEntity.kt | 2 +- .../horizon/di/HorizonOfflineModule.kt | 8 ++++---- .../features/dashboard/DashboardViewModel.kt | 4 ++-- 15 files changed, 39 insertions(+), 34 deletions(-) rename libs/horizon/src/main/java/com/instructure/horizon/database/{course => dao}/HorizonDashboardCourseDao.kt (93%) rename libs/horizon/src/main/java/com/instructure/horizon/database/{moduleitem => dao}/HorizonDashboardModuleItemDao.kt (90%) rename libs/horizon/src/main/java/com/instructure/horizon/database/{program => dao}/HorizonDashboardProgramDao.kt (89%) rename libs/horizon/src/main/java/com/instructure/horizon/database/{sync => dao}/HorizonSyncMetadataDao.kt (89%) rename libs/horizon/src/main/java/com/instructure/horizon/database/{course => entity}/HorizonDashboardCourseEntity.kt (95%) rename libs/horizon/src/main/java/com/instructure/horizon/database/{moduleitem => entity}/HorizonDashboardModuleItemEntity.kt (95%) rename libs/horizon/src/main/java/com/instructure/horizon/database/{program => entity}/HorizonDashboardProgramCourseRef.kt (94%) rename libs/horizon/src/main/java/com/instructure/horizon/database/{program => entity}/HorizonDashboardProgramEntity.kt (94%) rename libs/horizon/src/main/java/com/instructure/horizon/database/{sync => entity}/HorizonSyncMetadataEntity.kt (95%) 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 index 71208a6fc8..bfb408ae72 100644 --- 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 @@ -17,10 +17,10 @@ package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.GetCoursesQuery import com.instructure.canvasapi2.type.EnrollmentWorkflowState -import com.instructure.horizon.database.course.HorizonDashboardCourseDao -import com.instructure.horizon.database.course.HorizonDashboardCourseEntity -import com.instructure.horizon.database.sync.HorizonSyncMetadataDao -import com.instructure.horizon.database.sync.HorizonSyncMetadataEntity +import com.instructure.horizon.database.dao.HorizonDashboardCourseDao +import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonDashboardCourseEntity +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity import javax.inject.Inject class CourseEnrollmentLocalDataSource @Inject constructor( 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 index bdf8aa6eb5..cc28277999 100644 --- 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 @@ -18,8 +18,8 @@ package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject -import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemDao -import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemEntity +import com.instructure.horizon.database.dao.HorizonDashboardModuleItemDao +import com.instructure.horizon.database.entity.HorizonDashboardModuleItemEntity import com.instructure.horizon.model.LearningObjectType import java.text.SimpleDateFormat import java.util.Date diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt index e8a13f063a..64931bee05 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt @@ -17,10 +17,10 @@ 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.course.HorizonDashboardCourseDao -import com.instructure.horizon.database.program.HorizonDashboardProgramCourseRef -import com.instructure.horizon.database.program.HorizonDashboardProgramDao -import com.instructure.horizon.database.program.HorizonDashboardProgramEntity +import com.instructure.horizon.database.dao.HorizonDashboardCourseDao +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 javax.inject.Inject diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt index ed33d76ed2..fab5945f3c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt @@ -17,15 +17,15 @@ package com.instructure.horizon.database import androidx.room.Database import androidx.room.RoomDatabase -import com.instructure.horizon.database.course.HorizonDashboardCourseDao -import com.instructure.horizon.database.course.HorizonDashboardCourseEntity -import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemDao -import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemEntity -import com.instructure.horizon.database.program.HorizonDashboardProgramCourseRef -import com.instructure.horizon.database.program.HorizonDashboardProgramDao -import com.instructure.horizon.database.program.HorizonDashboardProgramEntity -import com.instructure.horizon.database.sync.HorizonSyncMetadataDao -import com.instructure.horizon.database.sync.HorizonSyncMetadataEntity +import com.instructure.horizon.database.dao.HorizonDashboardCourseDao +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.HorizonDashboardCourseEntity +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 @Database( entities = [ diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/course/HorizonDashboardCourseDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardCourseDao.kt similarity index 93% rename from libs/horizon/src/main/java/com/instructure/horizon/database/course/HorizonDashboardCourseDao.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardCourseDao.kt index f9fe9de0bf..afd7872e97 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/course/HorizonDashboardCourseDao.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardCourseDao.kt @@ -13,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.database.course +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.HorizonDashboardCourseEntity import kotlinx.coroutines.flow.Flow @Dao diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/moduleitem/HorizonDashboardModuleItemDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardModuleItemDao.kt similarity index 90% rename from libs/horizon/src/main/java/com/instructure/horizon/database/moduleitem/HorizonDashboardModuleItemDao.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardModuleItemDao.kt index 6537a25fd0..2b6eb2be21 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/moduleitem/HorizonDashboardModuleItemDao.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardModuleItemDao.kt @@ -13,12 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.database.moduleitem +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.HorizonDashboardModuleItemEntity @Dao interface HorizonDashboardModuleItemDao { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardProgramDao.kt similarity index 89% rename from libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramDao.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardProgramDao.kt index b072bb65f6..5eea0f6713 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramDao.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardProgramDao.kt @@ -13,12 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.database.program +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.HorizonDashboardProgramCourseRef +import com.instructure.horizon.database.entity.HorizonDashboardProgramEntity @Dao interface HorizonDashboardProgramDao { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/sync/HorizonSyncMetadataDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonSyncMetadataDao.kt similarity index 89% rename from libs/horizon/src/main/java/com/instructure/horizon/database/sync/HorizonSyncMetadataDao.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonSyncMetadataDao.kt index 0fdb2830a4..372fd90a84 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/sync/HorizonSyncMetadataDao.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonSyncMetadataDao.kt @@ -13,12 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.database.sync +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 @Dao interface HorizonSyncMetadataDao { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/course/HorizonDashboardCourseEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardCourseEntity.kt similarity index 95% rename from libs/horizon/src/main/java/com/instructure/horizon/database/course/HorizonDashboardCourseEntity.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardCourseEntity.kt index bfa218c58f..8ce54410a0 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/course/HorizonDashboardCourseEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardCourseEntity.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.database.course +package com.instructure.horizon.database.entity import androidx.room.Entity import androidx.room.Index diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/moduleitem/HorizonDashboardModuleItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardModuleItemEntity.kt similarity index 95% rename from libs/horizon/src/main/java/com/instructure/horizon/database/moduleitem/HorizonDashboardModuleItemEntity.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardModuleItemEntity.kt index 76b5ee79e5..8093762344 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/moduleitem/HorizonDashboardModuleItemEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardModuleItemEntity.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.database.moduleitem +package com.instructure.horizon.database.entity import androidx.room.Entity import androidx.room.Index diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramCourseRef.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramCourseRef.kt similarity index 94% rename from libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramCourseRef.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramCourseRef.kt index 110b40fe33..f9856c5078 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramCourseRef.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramCourseRef.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.database.program +package com.instructure.horizon.database.entity import androidx.room.Entity diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramEntity.kt similarity index 94% rename from libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramEntity.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramEntity.kt index 1d3202f7f9..59efbe77e1 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/program/HorizonDashboardProgramEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramEntity.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.database.program +package com.instructure.horizon.database.entity import androidx.room.Entity import androidx.room.PrimaryKey diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/sync/HorizonSyncMetadataEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt similarity index 95% rename from libs/horizon/src/main/java/com/instructure/horizon/database/sync/HorizonSyncMetadataEntity.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt index f555591a8e..4f8b4182d2 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/sync/HorizonSyncMetadataEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.database.sync +package com.instructure.horizon.database.entity import androidx.room.Entity import androidx.room.PrimaryKey diff --git a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt index 9fa1820055..62905d8ea4 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt @@ -18,10 +18,10 @@ 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.course.HorizonDashboardCourseDao -import com.instructure.horizon.database.moduleitem.HorizonDashboardModuleItemDao -import com.instructure.horizon.database.program.HorizonDashboardProgramDao -import com.instructure.horizon.database.sync.HorizonSyncMetadataDao +import com.instructure.horizon.database.dao.HorizonDashboardCourseDao +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 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 a2b7b1308a..633dbcbbc4 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 @@ -23,8 +23,8 @@ 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.sync.HorizonSyncMetadataDao -import com.instructure.horizon.database.sync.HorizonSyncMetadataEntity +import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.LocaleUtils import com.instructure.pandautils.utils.NetworkStateProvider From bdce3e91ebc852bfe75907d18c77965304661718 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Thu, 2 Apr 2026 11:33:45 +0200 Subject: [PATCH 07/31] Improve entity handling --- .../horizon/HorizonGetCoursesManager.kt | 46 ++++++++++++ .../CourseEnrollmentLocalDataSource.kt | 74 ++++++++----------- .../CourseEnrollmentNetworkDataSource.kt | 9 ++- .../datasource/ModuleItemLocalDataSource.kt | 52 +++++-------- .../datasource/ModuleItemNetworkDataSource.kt | 18 ++++- .../data/datasource/ProgramLocalDataSource.kt | 52 ++++++++----- .../repository/CourseEnrollmentRepository.kt | 6 +- .../data/repository/ModuleItemRepository.kt | 10 +-- .../data/repository/ProgramRepository.kt | 8 +- .../horizon/database/HorizonDatabase.kt | 10 +-- ...ao.kt => HorizonDashboardEnrollmentDao.kt} | 27 +++---- .../dao/HorizonDashboardModuleItemDao.kt | 10 +++ .../dao/HorizonDashboardProgramDao.kt | 15 +++- ...kt => HorizonDashboardEnrollmentEntity.kt} | 12 ++- .../HorizonDashboardProgramCourseRef.kt | 5 ++ .../entity/HorizonDashboardProgramEntity.kt | 5 ++ .../entity/HorizonSyncMetadataEntity.kt | 4 +- .../horizon/di/HorizonOfflineModule.kt | 6 +- .../usecase/GetDashboardCoursesUseCase.kt | 33 +++++---- .../domain/usecase/GetEnrollmentsUseCase.kt | 4 +- ...UseCase.kt => GetNextModuleItemUseCase.kt} | 8 +- .../features/dashboard/DashboardViewModel.kt | 2 +- .../widget/course/DashboardCourseViewModel.kt | 23 +++--- .../widget/course/DashboardMapper.kt | 59 +++++++-------- .../horizon/model/DashboardNextModuleItem.kt | 28 +++++++ 25 files changed, 309 insertions(+), 217 deletions(-) rename libs/horizon/src/main/java/com/instructure/horizon/database/dao/{HorizonDashboardCourseDao.kt => HorizonDashboardEnrollmentDao.kt} (53%) rename libs/horizon/src/main/java/com/instructure/horizon/database/entity/{HorizonDashboardCourseEntity.kt => HorizonDashboardEnrollmentEntity.kt} (85%) rename libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/{GetModuleItemsUseCase.kt => GetNextModuleItemUseCase.kt} (77%) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/model/DashboardNextModuleItem.kt 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/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentLocalDataSource.kt index bfb408ae72..8f29f71c5f 100644 --- 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 @@ -15,68 +15,54 @@ */ package com.instructure.horizon.data.datasource -import com.instructure.canvasapi2.GetCoursesQuery -import com.instructure.canvasapi2.type.EnrollmentWorkflowState -import com.instructure.horizon.database.dao.HorizonDashboardCourseDao +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.HorizonDashboardCourseEntity +import com.instructure.horizon.database.entity.HorizonDashboardEnrollmentEntity import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity import javax.inject.Inject class CourseEnrollmentLocalDataSource @Inject constructor( - private val courseDao: HorizonDashboardCourseDao, + private val enrollmentDao: HorizonDashboardEnrollmentDao, private val syncMetadataDao: HorizonSyncMetadataDao, ) { - suspend fun getEnrollments(): List { - return courseDao.getAll().map { entity -> - GetCoursesQuery.Enrollment( - id = entity.enrollmentId.toString(), - state = EnrollmentWorkflowState.safeValueOf(entity.enrollmentState), - lastActivityAt = null, - course = GetCoursesQuery.Course( - id = entity.courseId.toString(), - name = entity.courseName, - image_download_url = entity.courseImageUrl, - syllabus_body = null, - account = null, - usersConnection = GetCoursesQuery.UsersConnection( - nodes = listOf( - GetCoursesQuery.Node( - courseProgression = GetCoursesQuery.CourseProgression( - requirements = GetCoursesQuery.Requirements( - completionPercentage = entity.completionPercentage, - ), - incompleteModulesConnection = null, - ) - ) - ) - ), - ) + 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.mapNotNull { enrollment -> - val course = enrollment.course ?: return@mapNotNull null - val completionPercentage = course.usersConnection?.nodes - ?.firstOrNull()?.courseProgression?.requirements?.completionPercentage ?: 0.0 - HorizonDashboardCourseEntity( - enrollmentId = enrollment.id?.toLongOrNull() ?: return@mapNotNull null, - courseId = course.id.toLongOrNull() ?: return@mapNotNull null, - courseName = course.name, - courseImageUrl = course.image_download_url, - completionPercentage = completionPercentage, - enrollmentState = enrollment.state.rawValue, + 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, ) } - courseDao.replaceAll(entities) + enrollmentDao.replaceAll(entities) syncMetadataDao.upsert( HorizonSyncMetadataEntity( - key = HorizonSyncMetadataEntity.KEY_DASHBOARD_COURSES, + key = HorizonSyncMetadataEntity.KEY_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 index 35c82f2dfe..67ac235dea 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt @@ -15,9 +15,9 @@ */ package com.instructure.horizon.data.datasource -import com.instructure.canvasapi2.GetCoursesQuery 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 @@ -28,8 +28,11 @@ class CourseEnrollmentNetworkDataSource @Inject constructor( private val enrollmentApi: EnrollmentAPI.EnrollmentInterface, ) { - suspend fun getEnrollments(): List { - return horizonGetCoursesManager.getEnrollments(apiPrefs.user?.id ?: -1, forceNetwork = true).dataOrThrow + suspend fun getEnrollments(): List { + return horizonGetCoursesManager.getDashboardEnrollments( + userId = apiPrefs.user?.id ?: -1, + forceNetwork = true, + ).dataOrThrow } suspend fun acceptInvite(courseId: Long, enrollmentId: Long) { 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 index cc28277999..273cfb5b62 100644 --- 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 @@ -15,56 +15,40 @@ */ package com.instructure.horizon.data.datasource -import com.instructure.canvasapi2.models.ModuleContentDetails -import com.instructure.canvasapi2.models.ModuleItem -import com.instructure.canvasapi2.models.ModuleObject 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.text.SimpleDateFormat import java.util.Date -import java.util.Locale -import java.util.TimeZone import javax.inject.Inject class ModuleItemLocalDataSource @Inject constructor( private val moduleItemDao: HorizonDashboardModuleItemDao, ) { - suspend fun getModuleItemsForCourse(courseId: Long): List { - val entity = moduleItemDao.getFirstForCourse(courseId) ?: return emptyList() - val moduleItem = ModuleItem( - id = entity.moduleItemId, - moduleId = 0L, + 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 = entity.moduleItemType, - quizLti = entity.isQuizLti, + type = LearningObjectType.valueOf(entity.moduleItemType), + dueDate = entity.dueDateMs?.let { Date(it) }, estimatedDuration = entity.estimatedDuration, - moduleDetails = entity.dueDateMs?.let { ms -> - ModuleContentDetails(dueAt = isoFormatter.format(Date(ms))) - }, + isQuizLti = entity.isQuizLti, ) - return listOf(ModuleObject(items = listOf(moduleItem))) } - suspend fun saveModuleItem(courseId: Long, modules: List) { - val firstItem = modules.flatMap { it.items }.firstOrNull() ?: return + suspend fun saveNextModuleItem(item: DashboardNextModuleItem) { val entity = HorizonDashboardModuleItemEntity( - moduleItemId = firstItem.id, - courseId = courseId, - moduleItemTitle = firstItem.title.orEmpty(), - moduleItemType = if (firstItem.quizLti) LearningObjectType.ASSESSMENT.name - else LearningObjectType.fromApiString(firstItem.type.orEmpty()).name, - dueDateMs = firstItem.moduleDetails?.dueDate?.time, - estimatedDuration = firstItem.estimatedDuration, - isQuizLti = firstItem.quizLti, + moduleItemId = item.moduleItemId, + courseId = item.courseId, + moduleItemTitle = item.title, + moduleItemType = item.type.name, + dueDateMs = item.dueDate?.time, + estimatedDuration = item.estimatedDuration, + isQuizLti = item.isQuizLti, ) - moduleItemDao.insertAll(listOf(entity)) - } - - companion object { - private val isoFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+00:00", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } + 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 index a3a4939c3a..baa01de2f3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt @@ -18,20 +18,32 @@ package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.apis.ModuleAPI import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.model.DashboardNextModuleItem +import com.instructure.horizon.model.LearningObjectType import javax.inject.Inject class ModuleItemNetworkDataSource @Inject constructor( private val moduleApi: ModuleAPI.ModuleInterface, ) { - suspend fun getModuleItemsForCourse(courseId: Long): List { + suspend fun getNextModuleItemForCourse(courseId: Long): DashboardNextModuleItem? { val params = RestParams(isForceReadFromNetwork = true) - return moduleApi.getFirstPageModulesWithItems( + 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 index 64931bee05..20c4ba6cb4 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt @@ -17,17 +17,16 @@ package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement -import com.instructure.horizon.database.dao.HorizonDashboardCourseDao 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, - private val courseDao: HorizonDashboardCourseDao, ) { suspend fun getPrograms(): List { @@ -36,42 +35,55 @@ class ProgramLocalDataSource @Inject constructor( Program( id = programEntity.programId, name = programEntity.programName, - description = null, - startDate = null, - endDate = null, - variant = ProgramVariantType.UNKNOWN__, - sortedRequirements = refs.map { ref -> + 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 = "", - progressId = "", + id = ref.requirementId, + progressId = ref.progressId, courseId = ref.courseId, - required = false, + required = ref.required, + progress = ref.progress, enrollmentStatus = ref.enrollmentStatus?.let { ProgramProgressCourseEnrollmentStatus.safeValueOf(it) }, ) - } + }, ) } } - suspend fun savePrograms(programs: List) { - val courseIds = courseDao.getAllCourseIds().toSet() - val programEntities = programs.map { HorizonDashboardProgramEntity(it.id, it.name) } + 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 courseIds } - .map { req -> + .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.deleteAllRefs() - programDao.deleteAll() - programDao.insertAll(programEntities) - programDao.insertAllRefs(refs) + programDao.replaceAll(programEntities, refs) } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt index 2fb2b8730c..f68048afbd 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt @@ -15,7 +15,7 @@ */ package com.instructure.horizon.data.repository -import com.instructure.canvasapi2.GetCoursesQuery +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 @@ -30,7 +30,7 @@ class CourseEnrollmentRepository @Inject constructor( featureFlagProvider: FeatureFlagProvider, ) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { - suspend fun getEnrollments(): List { + suspend fun getEnrollments(): List { return if (shouldFetchFromNetwork()) { networkDataSource.getEnrollments() .also { if (shouldSync()) localDataSource.saveEnrollments(it) } @@ -39,6 +39,8 @@ class CourseEnrollmentRepository @Inject constructor( } } + suspend fun getEnrolledCourseIds(): List = localDataSource.getAllCourseIds() + suspend fun acceptInvite(courseId: Long, enrollmentId: Long) { networkDataSource.acceptInvite(courseId, enrollmentId) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt index a6b06d39de..e7127d6caf 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt @@ -15,9 +15,9 @@ */ package com.instructure.horizon.data.repository -import com.instructure.canvasapi2.models.ModuleObject 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 @@ -30,12 +30,12 @@ class ModuleItemRepository @Inject constructor( featureFlagProvider: FeatureFlagProvider, ) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { - suspend fun getModuleItemsForCourse(courseId: Long): List { + suspend fun getNextModuleItemForCourse(courseId: Long): DashboardNextModuleItem? { return if (shouldFetchFromNetwork()) { - networkDataSource.getModuleItemsForCourse(courseId) - .also { if (shouldSync()) localDataSource.saveModuleItem(courseId, it) } + networkDataSource.getNextModuleItemForCourse(courseId) + .also { item -> if (shouldSync() && item != null) localDataSource.saveNextModuleItem(item) } } else { - localDataSource.getModuleItemsForCourse(courseId) + localDataSource.getNextModuleItemForCourse(courseId) } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt index be3e618ca2..5ac37c4f6e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt @@ -26,6 +26,7 @@ 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) { @@ -33,7 +34,12 @@ class ProgramRepository @Inject constructor( suspend fun getPrograms(): List { return if (shouldFetchFromNetwork()) { networkDataSource.getPrograms() - .also { if (shouldSync()) localDataSource.savePrograms(it) } + .also { programs -> + if (shouldSync()) { + val enrolledCourseIds = enrollmentRepository.getEnrolledCourseIds().toSet() + localDataSource.savePrograms(programs, enrolledCourseIds) + } + } } else { localDataSource.getPrograms() } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt index fab5945f3c..cbf993da6e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt @@ -17,11 +17,11 @@ package com.instructure.horizon.database import androidx.room.Database import androidx.room.RoomDatabase -import com.instructure.horizon.database.dao.HorizonDashboardCourseDao +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.HorizonDashboardCourseEntity +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 @@ -29,16 +29,16 @@ import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity @Database( entities = [ - HorizonDashboardCourseEntity::class, + HorizonDashboardEnrollmentEntity::class, HorizonDashboardProgramEntity::class, HorizonDashboardProgramCourseRef::class, HorizonDashboardModuleItemEntity::class, HorizonSyncMetadataEntity::class, ], - version = 1, + version = 2, ) abstract class HorizonDatabase : RoomDatabase() { - abstract fun dashboardCourseDao(): HorizonDashboardCourseDao + 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/dao/HorizonDashboardCourseDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardEnrollmentDao.kt similarity index 53% rename from libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardCourseDao.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardEnrollmentDao.kt index afd7872e97..3e62613267 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardCourseDao.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardEnrollmentDao.kt @@ -20,32 +20,25 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction -import com.instructure.horizon.database.entity.HorizonDashboardCourseEntity -import kotlinx.coroutines.flow.Flow +import com.instructure.horizon.database.entity.HorizonDashboardEnrollmentEntity @Dao -abstract class HorizonDashboardCourseDao { +interface HorizonDashboardEnrollmentDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insertAll(entities: List) + suspend fun insertAll(entities: List) - @Query("SELECT * FROM horizon_dashboard_courses") - abstract fun observeAll(): Flow> + @Query("SELECT * FROM horizon_dashboard_enrollments") + suspend fun getAll(): List - @Query("SELECT * FROM horizon_dashboard_courses") - abstract suspend fun getAll(): List + @Query("SELECT courseId FROM horizon_dashboard_enrollments") + suspend fun getAllCourseIds(): List - @Query("SELECT courseId FROM horizon_dashboard_courses") - abstract suspend fun getAllCourseIds(): List - - @Query("SELECT COUNT(*) FROM horizon_dashboard_courses") - abstract suspend fun count(): Int - - @Query("DELETE FROM horizon_dashboard_courses") - abstract suspend fun deleteAll() + @Query("DELETE FROM horizon_dashboard_enrollments") + suspend fun deleteAll() @Transaction - open suspend fun replaceAll(entities: List) { + 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 index 2b6eb2be21..a567ece498 100644 --- 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 @@ -19,6 +19,7 @@ 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 @@ -30,6 +31,15 @@ interface HorizonDashboardModuleItemDao { @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 index 5eea0f6713..701b10e33a 100644 --- 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 @@ -19,6 +19,7 @@ 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 @@ -34,9 +35,6 @@ interface HorizonDashboardProgramDao { @Query("SELECT * FROM horizon_dashboard_programs") suspend fun getAll(): List - @Query("SELECT * FROM horizon_dashboard_program_course_refs WHERE courseId = :courseId") - suspend fun getRefsForCourse(courseId: Long): List - @Query("SELECT * FROM horizon_dashboard_program_course_refs WHERE programId = :programId") suspend fun getRefsForProgram(programId: String): List @@ -45,4 +43,15 @@ interface HorizonDashboardProgramDao { @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/entity/HorizonDashboardCourseEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardEnrollmentEntity.kt similarity index 85% rename from libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardCourseEntity.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardEnrollmentEntity.kt index 8ce54410a0..a5f4673b76 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardCourseEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardEnrollmentEntity.kt @@ -16,18 +16,16 @@ package com.instructure.horizon.database.entity import androidx.room.Entity -import androidx.room.Index import androidx.room.PrimaryKey -@Entity( - tableName = "horizon_dashboard_courses", - indices = [Index("courseId")] -) -data class HorizonDashboardCourseEntity( +@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, - val enrollmentState: String, ) 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 index f9856c5078..d2da522403 100644 --- 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 @@ -24,5 +24,10 @@ import androidx.room.Entity 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 index 59efbe77e1..e6fa5d7d35 100644 --- 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 @@ -22,4 +22,9 @@ import androidx.room.PrimaryKey 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 index 4f8b4182d2..7ac3c41d6e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt @@ -24,6 +24,8 @@ data class HorizonSyncMetadataEntity( val lastSyncedAtMs: Long, ) { companion object { - const val KEY_DASHBOARD_COURSES = "dashboard_courses" + const val KEY_DASHBOARD_ENROLLMENTS = "dashboard_enrollments" + const val KEY_DASHBOARD_PROGRAMS = "dashboard_programs" + const val KEY_DASHBOARD_MODULE_ITEMS = "dashboard_module_items" } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt index 62905d8ea4..99124f89d8 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt @@ -18,7 +18,7 @@ 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.HorizonDashboardCourseDao +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 @@ -41,8 +41,8 @@ class HorizonOfflineModule { } @Provides - fun provideHorizonDashboardCourseDao(db: HorizonDatabase): HorizonDashboardCourseDao { - return db.dashboardCourseDao() + fun provideHorizonDashboardEnrollmentDao(db: HorizonDatabase): HorizonDashboardEnrollmentDao { + return db.dashboardEnrollmentDao() } @Provides 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 index a841ac4610..b8d1249397 100644 --- 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 @@ -15,23 +15,24 @@ */ package com.instructure.horizon.domain.usecase -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.models.ModuleItem -import com.instructure.canvasapi2.type.EnrollmentWorkflowState +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 enrollments: List, val programs: List, - val nextModuleItemByCourseId: Map, + val unenrolledPrograms: List, + val nextModuleItemByCourseId: Map, ) class GetDashboardCoursesUseCase @Inject constructor( private val getEnrollmentsUseCase: GetEnrollmentsUseCase, private val getProgramsUseCase: GetProgramsUseCase, - private val getModuleItemsUseCase: GetModuleItemsUseCase, + private val getNextModuleItemUseCase: GetNextModuleItemUseCase, private val acceptCourseInviteUseCase: AcceptCourseInviteUseCase, ) : BaseUseCase() { @@ -41,13 +42,13 @@ class GetDashboardCoursesUseCase @Inject constructor( var enrollments = getEnrollmentsUseCase() val programs = getProgramsUseCase() - val invitations = enrollments.filter { it.state == EnrollmentWorkflowState.invited } + val invitations = enrollments.filter { it.enrollmentState == DashboardEnrollment.STATE_INVITED } if (invitations.isNotEmpty()) { invitations.forEach { enrollment -> acceptCourseInviteUseCase( AcceptCourseInviteParams( - courseId = enrollment.course?.id?.toLongOrNull() ?: return@forEach, - enrollmentId = enrollment.id?.toLongOrNull() ?: return@forEach, + courseId = enrollment.courseId, + enrollmentId = enrollment.enrollmentId, ) ) } @@ -55,16 +56,20 @@ class GetDashboardCoursesUseCase @Inject constructor( } val nextModuleItemByCourseId = enrollments - .filter { it.state == EnrollmentWorkflowState.active } - .mapNotNull { it.course?.id?.toLongOrNull() } - .associate { courseId -> - courseId to getModuleItemsUseCase(courseId).flatMap { it.items }.firstOrNull() + .filter { it.enrollmentState == DashboardEnrollment.STATE_ACTIVE } + .associate { enrollment -> + enrollment.courseId to getNextModuleItemUseCase(enrollment.courseId) } + val unenrolledPrograms = programs.filter { program -> + program.sortedRequirements.none { it.enrollmentStatus == ProgramProgressCourseEnrollmentStatus.ENROLLED } + } + return DashboardCoursesData( enrollments = enrollments, programs = programs, + unenrolledPrograms = unenrolledPrograms, nextModuleItemByCourseId = nextModuleItemByCourseId, ) } -} \ No newline at end of file +} 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 index e43f61b54a..17f2ecca02 100644 --- 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 @@ -15,14 +15,14 @@ */ package com.instructure.horizon.domain.usecase -import com.instructure.canvasapi2.GetCoursesQuery +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>() { +) : BaseUseCase>() { suspend operator fun invoke() = invoke(Unit) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModuleItemsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetNextModuleItemUseCase.kt similarity index 77% rename from libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModuleItemsUseCase.kt rename to libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetNextModuleItemUseCase.kt index 960446ebc4..a86f9ab79e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModuleItemsUseCase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetNextModuleItemUseCase.kt @@ -15,14 +15,14 @@ */ package com.instructure.horizon.domain.usecase -import com.instructure.canvasapi2.models.ModuleObject 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 GetModuleItemsUseCase @Inject constructor( +class GetNextModuleItemUseCase @Inject constructor( private val repository: ModuleItemRepository, -) : BaseUseCase>() { +) : BaseUseCase() { - override suspend fun execute(params: Long) = repository.getModuleItemsForCourse(params) + override suspend fun execute(params: Long) = repository.getNextModuleItemForCourse(params) } 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 633dbcbbc4..4ebb67259c 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 @@ -82,7 +82,7 @@ class DashboardViewModel @Inject constructor( .collect { isOnline -> if (featureFlagProvider.offlineEnabled()) { val lastSyncedAt = if (!isOnline) { - syncMetadataDao.getLastSyncedAt(HorizonSyncMetadataEntity.KEY_DASHBOARD_COURSES) + syncMetadataDao.getLastSyncedAt(HorizonSyncMetadataEntity.KEY_DASHBOARD_ENROLLMENTS) } else { null } 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 672f33fd2e..aa509e8103 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 @@ -17,7 +17,6 @@ package com.instructure.horizon.features.dashboard.widget.course import android.content.Context import androidx.lifecycle.viewModelScope -import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.domain.usecase.GetDashboardCoursesUseCase @@ -27,9 +26,8 @@ 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.horizon.model.DashboardNextModuleItem import com.instructure.horizon.offline.HorizonOfflineViewModel -import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.formatIsoDuration @@ -103,13 +101,11 @@ class DashboardCourseViewModel @Inject constructor( context, programs = data.programs, nextModuleForCourse = { courseId -> - courseId?.let { data.nextModuleItemByCourseId[it] }?.let { mapToModuleItemState(courseId, it) } + data.nextModuleItemByCourseId[courseId]?.let { mapToModuleItemState(it) } }, ) - val programCardStates = data.programs - .filter { program -> program.sortedRequirements.none { it.enrollmentStatus == ProgramProgressCourseEnrollmentStatus.ENROLLED } } - .mapToDashboardCourseCardState(context) + val programCardStates = data.unenrolledPrograms.mapToDashboardCourseCardState(context) _uiState.update { it.copy( @@ -119,14 +115,13 @@ class DashboardCourseViewModel @Inject constructor( } } - private fun mapToModuleItemState(courseId: Long, moduleItem: ModuleItem): DashboardCourseCardModuleItemState { + private fun mapToModuleItemState(moduleItem: DashboardNextModuleItem): DashboardCourseCardModuleItemState { return DashboardCourseCardModuleItemState( - moduleItemTitle = moduleItem.title.orEmpty(), - moduleItemType = if (moduleItem.quizLti) LearningObjectType.ASSESSMENT - else LearningObjectType.fromApiString(moduleItem.type.orEmpty()), - dueDate = moduleItem.moduleDetails?.dueDate, + moduleItemTitle = moduleItem.title, + moduleItemType = moduleItem.type, + dueDate = moduleItem.dueDate, estimatedDuration = moduleItem.estimatedDuration?.formatIsoDuration(context), - onClickAction = CardClickAction.NavigateToModuleItem(courseId, moduleItem.id), + 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, +) From 2144299fc9c893e74c8e465cdc72fee482c89898 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Thu, 2 Apr 2026 11:49:10 +0200 Subject: [PATCH 08/31] Refactor feature keys --- .../CourseEnrollmentLocalDataSource.kt | 3 +- .../horizon/database/HorizonDatabase.kt | 2 ++ .../horizon/database/HorizonTypeConverters.kt | 28 +++++++++++++++++++ .../database/dao/HorizonSyncMetadataDao.kt | 5 ++-- .../entity/HorizonSyncMetadataEntity.kt | 16 +++++------ .../features/dashboard/DashboardViewModel.kt | 4 +-- 6 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/HorizonTypeConverters.kt 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 index 8f29f71c5f..9cf84388b9 100644 --- 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 @@ -20,6 +20,7 @@ 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( @@ -58,7 +59,7 @@ class CourseEnrollmentLocalDataSource @Inject constructor( enrollmentDao.replaceAll(entities) syncMetadataDao.upsert( HorizonSyncMetadataEntity( - key = HorizonSyncMetadataEntity.KEY_DASHBOARD_ENROLLMENTS, + dataType = SyncDataType.DASHBOARD_ENROLLMENTS, lastSyncedAtMs = System.currentTimeMillis(), ) ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt index cbf993da6e..fca3d4a40e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt @@ -17,6 +17,7 @@ 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 @@ -27,6 +28,7 @@ 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, 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/HorizonSyncMetadataDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonSyncMetadataDao.kt index 372fd90a84..029413ea98 100644 --- 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 @@ -20,6 +20,7 @@ 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 { @@ -27,6 +28,6 @@ interface HorizonSyncMetadataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(entity: HorizonSyncMetadataEntity) - @Query("SELECT lastSyncedAtMs FROM horizon_sync_metadata WHERE `key` = :key") - suspend fun getLastSyncedAt(key: String): Long? + @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/HorizonSyncMetadataEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt index 7ac3c41d6e..852461480c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt @@ -18,14 +18,14 @@ 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 key: String, + @PrimaryKey val dataType: SyncDataType, val lastSyncedAtMs: Long, -) { - companion object { - const val KEY_DASHBOARD_ENROLLMENTS = "dashboard_enrollments" - const val KEY_DASHBOARD_PROGRAMS = "dashboard_programs" - const val KEY_DASHBOARD_MODULE_ITEMS = "dashboard_module_items" - } -} +) \ No newline at end of file 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 4ebb67259c..25caa662c9 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 @@ -24,7 +24,7 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.database.dao.HorizonSyncMetadataDao -import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity +import com.instructure.horizon.database.entity.SyncDataType import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.LocaleUtils import com.instructure.pandautils.utils.NetworkStateProvider @@ -82,7 +82,7 @@ class DashboardViewModel @Inject constructor( .collect { isOnline -> if (featureFlagProvider.offlineEnabled()) { val lastSyncedAt = if (!isOnline) { - syncMetadataDao.getLastSyncedAt(HorizonSyncMetadataEntity.KEY_DASHBOARD_ENROLLMENTS) + syncMetadataDao.getLastSyncedAt(SyncDataType.DASHBOARD_ENROLLMENTS) } else { null } From 63c70e2f22e433e84c9a2c3e8b8c512f48382bc5 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Thu, 2 Apr 2026 12:19:37 +0200 Subject: [PATCH 09/31] Implement sync date usecase --- .../domain/usecase/GetLastSyncedAtUseCase.kt | 28 +++++++++++++++++++ .../features/dashboard/DashboardViewModel.kt | 6 ++-- 2 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLastSyncedAtUseCase.kt 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..4a26784a9e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLastSyncedAtUseCase.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.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() { + + override suspend fun execute(params: SyncDataType): Long? = syncMetadataDao.getLastSyncedAt(params) +} \ No newline at end of file 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 25caa662c9..b5429a301f 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 @@ -23,8 +23,8 @@ 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.dao.HorizonSyncMetadataDao 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.LocaleUtils import com.instructure.pandautils.utils.NetworkStateProvider @@ -49,7 +49,7 @@ class DashboardViewModel @Inject constructor( private val dashboardEventHandler: DashboardEventHandler, private val networkStateProvider: NetworkStateProvider, private val featureFlagProvider: FeatureFlagProvider, - private val syncMetadataDao: HorizonSyncMetadataDao, + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase, ) : ViewModel() { private val _uiState = MutableStateFlow(DashboardUiState(onSnackbarDismiss = ::dismissSnackbar, updateExternalShouldRefresh = ::updateExternalShouldRefresh)) @@ -82,7 +82,7 @@ class DashboardViewModel @Inject constructor( .collect { isOnline -> if (featureFlagProvider.offlineEnabled()) { val lastSyncedAt = if (!isOnline) { - syncMetadataDao.getLastSyncedAt(SyncDataType.DASHBOARD_ENROLLMENTS) + getLastSyncedAtUseCase(SyncDataType.DASHBOARD_ENROLLMENTS) } else { null } From 9d6bf2daff784d097b4a1563df54649c9e69349f Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Thu, 2 Apr 2026 13:51:56 +0200 Subject: [PATCH 10/31] Update FeatureFlagProvider.kt --- .../com/instructure/pandautils/utils/FeatureFlagProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt index 8d493122ff..14df17cf46 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt @@ -53,7 +53,7 @@ class FeatureFlagProvider( } suspend fun offlineEnabled(): Boolean { - return true//checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary + return checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary } private suspend fun checkEnvironmentFeatureFlag(featureFlag: String): Boolean { From c9451c1aed8683c544b477e7505785a2176f6bed Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Tue, 7 Apr 2026 13:20:34 +0200 Subject: [PATCH 11/31] Implement offline architecture for Learn screen --- .../CourseEnrollmentNetworkDataSource.kt | 6 +- .../LearnLearningLibraryLocalDataSource.kt | 247 ++++++++++++++++++ .../LearnLearningLibraryNetworkDataSource.kt | 54 ++++ .../LearnMyContentLocalDataSource.kt | 152 +++++++++++ .../LearnMyContentNetworkDataSource.kt | 46 ++++ .../datasource/ModuleItemNetworkDataSource.kt | 4 +- .../datasource/ProgramNetworkDataSource.kt | 4 +- .../repository/CourseEnrollmentRepository.kt | 4 +- .../LearnLearningLibraryRepository.kt | 70 +++++ .../repository/LearnMyContentRepository.kt | 60 +++++ .../data/repository/ModuleItemRepository.kt | 4 +- .../data/repository/ProgramRepository.kt | 4 +- .../horizon/database/HorizonDatabase.kt | 16 +- .../database/dao/HorizonLearnCollectionDao.kt | 57 ++++ .../database/dao/HorizonLearnItemDao.kt | 42 +++ .../database/dao/HorizonLearnSavedItemDao.kt | 42 +++ .../entity/HorizonLearnCollectionEntity.kt | 30 +++ .../HorizonLearnCollectionItemEntity.kt | 58 ++++ .../database/entity/HorizonLearnItemEntity.kt | 57 ++++ .../entity/HorizonLearnSavedItemEntity.kt | 53 ++++ .../entity/HorizonSyncMetadataEntity.kt | 3 + .../horizon/di/HorizonOfflineModule.kt | 18 ++ .../GetLearnLearningLibrariesUseCase.kt | 30 +++ .../GetLearnLearningLibraryItemsUseCase.kt | 52 ++++ ...rnLearningLibraryRecommendationsUseCase.kt | 34 +++ .../GetLearnMyContentCompletedItemsUseCase.kt | 51 ++++ ...GetLearnMyContentInProgressItemsUseCase.kt | 51 ++++ ...LearnLearningLibraryItemBookmarkUseCase.kt | 33 +++ .../horizon/features/learn/LearnRepository.kt | 12 +- .../horizon/features/learn/LearnViewModel.kt | 37 ++- .../LearnLearningLibraryListRepository.kt | 22 +- .../list/LearnLearningLibraryListViewModel.kt | 170 ++++-------- .../common/LearnMyContentRepository.kt | 85 ------ .../common/LearnMyContentViewModel.kt | 45 ++-- .../LearnMyContentCompletedViewModel.kt | 39 +-- .../LearnMyContentInProgressViewModel.kt | 38 +-- .../saved/LearnMyContentSavedRepository.kt | 33 --- .../saved/LearnMyContentSavedViewModel.kt | 63 +++-- .../LearnLearningLibraryListRepositoryTest.kt | 126 +++++---- .../LearnLearningLibraryListViewModelTest.kt | 23 +- .../LearnMyContentCompletedViewModelTest.kt | 108 +++----- .../LearnMyContentInProgressViewModelTest.kt | 153 +++++------ .../saved/LearnMyContentSavedViewModelTest.kt | 112 ++++---- .../pandautils/utils/FeatureFlagProvider.kt | 2 +- 44 files changed, 1719 insertions(+), 631 deletions(-) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryLocalDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryNetworkDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentNetworkDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnLearningLibraryRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnMyContentRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCollectionDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnItemDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnSavedItemDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCollectionEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCollectionItemEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnItemEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnSavedItemEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibrariesUseCase.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibraryItemsUseCase.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibraryRecommendationsUseCase.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnMyContentCompletedItemsUseCase.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnMyContentInProgressItemsUseCase.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/ToggleLearnLearningLibraryItemBookmarkUseCase.kt delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentRepository.kt delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedRepository.kt diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt index 67ac235dea..d971c0ade8 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt @@ -28,10 +28,12 @@ class CourseEnrollmentNetworkDataSource @Inject constructor( private val enrollmentApi: EnrollmentAPI.EnrollmentInterface, ) { - suspend fun getEnrollments(): List { + suspend fun getEnrollments( + forceRefresh: Boolean, + ): List { return horizonGetCoursesManager.getDashboardEnrollments( userId = apiPrefs.user?.id ?: -1, - forceNetwork = true, + forceNetwork = forceRefresh, ).dataOrThrow } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryLocalDataSource.kt new file mode 100644 index 0000000000..7dde693cf3 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryLocalDataSource.kt @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.models.journey.learninglibrary.CanvasCourseInfo +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryModuleInfo +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo +import com.instructure.horizon.database.dao.HorizonLearnCollectionDao +import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao +import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonLearnCollectionEntity +import com.instructure.horizon.database.entity.HorizonLearnCollectionItemEntity +import com.instructure.horizon.database.entity.HorizonLearnSavedItemEntity +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity +import com.instructure.horizon.database.entity.SyncDataType +import java.util.Date +import javax.inject.Inject + +class LearnLearningLibraryLocalDataSource @Inject constructor( + private val collectionDao: HorizonLearnCollectionDao, + private val savedItemDao: HorizonLearnSavedItemDao, + private val syncMetadataDao: HorizonSyncMetadataDao, +) { + + suspend fun getEnrolledLearningLibraries(): List { + val collections = collectionDao.getAllCollections() + return collections.map { collection -> + val items = collectionDao.getItemsByCollectionId(collection.id).map { it.toModel() } + collection.toModel(items) + } + } + + suspend fun saveEnrolledLearningLibraries(collections: List) { + val collectionEntities = collections.map { it.toEntity() } + val itemEntities = collections.flatMap { collection -> + collection.items.map { it.toCollectionItemEntity(collection.id) } + } + collectionDao.replaceAll(collectionEntities, itemEntities) + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + dataType = SyncDataType.LEARN_LIBRARY_COLLECTIONS, + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } + + suspend fun getSavedItems(): LearningLibraryCollectionItemsResponse { + val items = savedItemDao.getAll().map { it.toModel() } + return LearningLibraryCollectionItemsResponse( + items = items, + pageInfo = LearningLibraryPageInfo( + nextCursor = null, + previousCursor = null, + hasNextPage = false, + hasPreviousPage = false, + totalCount = items.size, + pageCursors = null, + ) + ) + } + + suspend fun saveSavedItems(items: List) { + val entities = items.map { it.toSavedItemEntity() } + savedItemDao.replaceAll(entities) + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + dataType = SyncDataType.LEARN_SAVED_ITEMS, + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } + + private fun HorizonLearnCollectionEntity.toModel(items: List): EnrolledLearningLibraryCollection { + return EnrolledLearningLibraryCollection( + id = id, + name = name, + publicName = publicName, + description = description, + createdAt = Date(createdAtMs), + updatedAt = Date(updatedAtMs), + totalItemCount = totalItemCount, + items = items, + ) + } + + private fun HorizonLearnCollectionItemEntity.toModel(): LearningLibraryCollectionItem { + val canvasCourse = if (canvasCourseId != null && canvasUrl != null) { + CanvasCourseInfo( + courseId = canvasCourseId, + canvasUrl = canvasUrl, + courseName = courseName, + courseImageUrl = courseImageUrl, + moduleCount = moduleCount ?: 0.0, + moduleItemCount = moduleItemCount ?: 0.0, + estimatedDurationMinutes = estimatedDurationMinutes, + ) + } else null + val moduleInfo = if (moduleItemId != null) { + LearningLibraryModuleInfo( + moduleId = moduleId, + moduleItemId = moduleItemId, + moduleItemType = moduleItemType, + resourceId = resourceId, + ) + } else null + return LearningLibraryCollectionItem( + id = id, + libraryId = libraryId, + itemType = CollectionItemType.valueOf(itemType), + displayOrder = displayOrder, + canvasCourse = canvasCourse, + moduleInfo = moduleInfo, + programId = programId, + programCourseId = programCourseId, + createdAt = Date(createdAtMs), + updatedAt = Date(updatedAtMs), + isBookmarked = isBookmarked, + completionPercentage = completionPercentage, + isEnrolledInCanvas = isEnrolledInCanvas, + canvasEnrollmentId = canvasEnrollmentId, + ) + } + + private fun HorizonLearnSavedItemEntity.toModel(): LearningLibraryCollectionItem { + val canvasCourse = if (canvasCourseId != null && canvasUrl != null) { + CanvasCourseInfo( + courseId = canvasCourseId, + canvasUrl = canvasUrl, + courseName = courseName, + courseImageUrl = courseImageUrl, + moduleCount = moduleCount ?: 0.0, + moduleItemCount = moduleItemCount ?: 0.0, + estimatedDurationMinutes = estimatedDurationMinutes, + ) + } else null + val moduleInfo = if (moduleItemId != null) { + LearningLibraryModuleInfo( + moduleId = moduleId, + moduleItemId = moduleItemId, + moduleItemType = moduleItemType, + resourceId = resourceId, + ) + } else null + return LearningLibraryCollectionItem( + id = id, + libraryId = libraryId, + itemType = CollectionItemType.valueOf(itemType), + displayOrder = displayOrder, + canvasCourse = canvasCourse, + moduleInfo = moduleInfo, + programId = programId, + programCourseId = programCourseId, + createdAt = Date(createdAtMs), + updatedAt = Date(updatedAtMs), + isBookmarked = isBookmarked, + completionPercentage = completionPercentage, + isEnrolledInCanvas = isEnrolledInCanvas, + canvasEnrollmentId = canvasEnrollmentId, + ) + } + + private fun EnrolledLearningLibraryCollection.toEntity(): HorizonLearnCollectionEntity { + return HorizonLearnCollectionEntity( + id = id, + name = name, + publicName = publicName, + description = description, + createdAtMs = createdAt.time, + updatedAtMs = updatedAt.time, + totalItemCount = totalItemCount, + ) + } + + private fun LearningLibraryCollectionItem.toCollectionItemEntity(collectionId: String): HorizonLearnCollectionItemEntity { + return HorizonLearnCollectionItemEntity( + id = id, + collectionId = collectionId, + libraryId = libraryId, + itemType = itemType.name, + displayOrder = displayOrder, + canvasCourseId = canvasCourse?.courseId, + canvasUrl = canvasCourse?.canvasUrl, + courseName = canvasCourse?.courseName, + courseImageUrl = canvasCourse?.courseImageUrl, + moduleCount = canvasCourse?.moduleCount, + moduleItemCount = canvasCourse?.moduleItemCount, + estimatedDurationMinutes = canvasCourse?.estimatedDurationMinutes, + moduleId = moduleInfo?.moduleId, + moduleItemId = moduleInfo?.moduleItemId, + moduleItemType = moduleInfo?.moduleItemType, + resourceId = moduleInfo?.resourceId, + programId = programId, + programCourseId = programCourseId, + createdAtMs = createdAt.time, + updatedAtMs = updatedAt.time, + isBookmarked = isBookmarked, + completionPercentage = completionPercentage, + isEnrolledInCanvas = isEnrolledInCanvas, + canvasEnrollmentId = canvasEnrollmentId, + ) + } + + private fun LearningLibraryCollectionItem.toSavedItemEntity(): HorizonLearnSavedItemEntity { + return HorizonLearnSavedItemEntity( + id = id, + libraryId = libraryId, + itemType = itemType.name, + displayOrder = displayOrder, + canvasCourseId = canvasCourse?.courseId, + canvasUrl = canvasCourse?.canvasUrl, + courseName = canvasCourse?.courseName, + courseImageUrl = canvasCourse?.courseImageUrl, + moduleCount = canvasCourse?.moduleCount, + moduleItemCount = canvasCourse?.moduleItemCount, + estimatedDurationMinutes = canvasCourse?.estimatedDurationMinutes, + moduleId = moduleInfo?.moduleId, + moduleItemId = moduleInfo?.moduleItemId, + moduleItemType = moduleInfo?.moduleItemType, + resourceId = moduleInfo?.resourceId, + programId = programId, + programCourseId = programCourseId, + createdAtMs = createdAt.time, + updatedAtMs = updatedAt.time, + isBookmarked = isBookmarked, + completionPercentage = completionPercentage, + isEnrolledInCanvas = isEnrolledInCanvas, + canvasEnrollmentId = canvasEnrollmentId, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryNetworkDataSource.kt new file mode 100644 index 0000000000..7746c99393 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryNetworkDataSource.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse +import javax.inject.Inject + +class LearnLearningLibraryNetworkDataSource @Inject constructor( + private val getLearningLibraryManager: GetLearningLibraryManager, +) { + + suspend fun getEnrolledLearningLibraries(limit: Int, forceRefresh: Boolean): List { + return getLearningLibraryManager.getEnrolledLearningLibraryCollections(limit, forceNetwork = forceRefresh).collections + } + + suspend fun getLearningLibraryItems( + cursor: String?, + limit: Int?, + searchQuery: String?, + typeFilter: CollectionItemType?, + bookmarkedOnly: Boolean, + completedOnly: Boolean, + sortBy: CollectionItemSortOption?, + forceRefresh: Boolean, + ): LearningLibraryCollectionItemsResponse { + return getLearningLibraryManager.getLearningLibraryCollectionItems( + cursor = cursor, + limit = limit, + bookmarkedOnly = bookmarkedOnly, + completedOnly = completedOnly, + searchTerm = searchQuery, + types = typeFilter?.let { listOf(it) }, + sortBy = sortBy, + forceNetwork = forceRefresh, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt new file mode 100644 index 0000000000..0662f1dbb7 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo +import com.instructure.canvasapi2.models.journey.mycontent.CourseEnrollmentItem +import com.instructure.canvasapi2.models.journey.mycontent.LearnItem +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse +import com.instructure.canvasapi2.models.journey.mycontent.ProgramEnrollmentItem +import com.instructure.horizon.database.dao.HorizonLearnItemDao +import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonLearnItemEntity +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity +import com.instructure.horizon.database.entity.SyncDataType +import java.util.Date +import javax.inject.Inject + +class LearnMyContentLocalDataSource @Inject constructor( + private val learnItemDao: HorizonLearnItemDao, + private val syncMetadataDao: HorizonSyncMetadataDao, +) { + + suspend fun getLearnItems(queryKey: String): LearnItemsResponse { + val items = learnItemDao.getByQueryKey(queryKey).map { it.toModel() } + return LearnItemsResponse( + items = items, + pageInfo = LearningLibraryPageInfo( + nextCursor = null, + previousCursor = null, + hasNextPage = false, + hasPreviousPage = false, + totalCount = items.size, + pageCursors = null, + ) + ) + } + + suspend fun saveLearnItems(items: List, queryKey: String) { + val entities = items.map { it.toEntity(queryKey) } + learnItemDao.replaceByQueryKey(entities, queryKey) + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + dataType = SyncDataType.LEARN_MY_CONTENT_ITEMS, + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } + + private fun HorizonLearnItemEntity.toModel(): LearnItem { + return when (itemType) { + LearnItemType.PROGRAM.name -> ProgramEnrollmentItem( + id = id, + name = name, + position = position, + enrolledAt = enrolledAtMs?.let { Date(it) }, + completionPercentage = completionPercentage, + startDate = startDateMs?.let { Date(it) }, + endDate = endDateMs?.let { Date(it) }, + status = enrollmentStatus.orEmpty(), + description = description, + variant = variant.orEmpty(), + estimatedDurationMinutes = estimatedDurationMinutes, + courseCount = courseCount ?: 0, + ) + else -> CourseEnrollmentItem( + id = id, + name = name, + position = position, + enrolledAt = enrolledAtMs?.let { Date(it) }, + completionPercentage = completionPercentage, + startAt = startAtMs?.let { Date(it) }, + endAt = endAtMs?.let { Date(it) }, + requirementCount = requirementCount, + requirementCompletedCount = requirementCompletedCount, + completedAt = completedAtMs?.let { Date(it) }, + grade = grade, + imageUrl = imageUrl, + workflowState = workflowState.orEmpty(), + lastActivityAt = lastActivityAtMs?.let { Date(it) }, + ) + } + } + + private fun LearnItem.toEntity(queryKey: String): HorizonLearnItemEntity { + return when (this) { + is ProgramEnrollmentItem -> HorizonLearnItemEntity( + id = id, + queryKey = queryKey, + itemType = LearnItemType.PROGRAM.name, + name = name, + position = position, + enrolledAtMs = enrolledAt?.time, + completionPercentage = completionPercentage, + startDateMs = startDate?.time, + endDateMs = endDate?.time, + enrollmentStatus = status, + description = description, + variant = variant, + estimatedDurationMinutes = estimatedDurationMinutes, + courseCount = courseCount, + startAtMs = null, + endAtMs = null, + requirementCount = null, + requirementCompletedCount = null, + completedAtMs = null, + grade = null, + imageUrl = null, + workflowState = null, + lastActivityAtMs = null, + ) + is CourseEnrollmentItem -> HorizonLearnItemEntity( + id = id, + queryKey = queryKey, + itemType = LearnItemType.COURSE.name, + name = name, + position = position, + enrolledAtMs = enrolledAt?.time, + completionPercentage = completionPercentage, + startDateMs = null, + endDateMs = null, + enrollmentStatus = null, + description = null, + variant = null, + estimatedDurationMinutes = null, + courseCount = null, + startAtMs = startAt?.time, + endAtMs = endAt?.time, + requirementCount = requirementCount, + requirementCompletedCount = requirementCompletedCount, + completedAtMs = completedAt?.time, + grade = grade, + imageUrl = imageUrl, + workflowState = workflowState, + lastActivityAtMs = lastActivityAt?.time, + ) + } + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentNetworkDataSource.kt new file mode 100644 index 0000000000..80315e88c6 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentNetworkDataSource.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.MyContentManager +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse +import javax.inject.Inject + +class LearnMyContentNetworkDataSource @Inject constructor( + private val myContentManager: MyContentManager, +) { + + suspend fun getLearnItems( + cursor: String?, + searchQuery: String?, + sortBy: CollectionItemSortOption?, + status: List?, + itemTypes: List?, + forceRefresh: Boolean, + ): LearnItemsResponse { + return myContentManager.getLearnItems( + cursor = cursor, + searchTerm = searchQuery, + sortBy = sortBy, + status = status, + itemTypes = itemTypes, + forceNetwork = forceRefresh, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt index baa01de2f3..0c7b867f14 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt @@ -26,8 +26,8 @@ class ModuleItemNetworkDataSource @Inject constructor( private val moduleApi: ModuleAPI.ModuleInterface, ) { - suspend fun getNextModuleItemForCourse(courseId: Long): DashboardNextModuleItem? { - val params = RestParams(isForceReadFromNetwork = true) + suspend fun getNextModuleItemForCourse(courseId: Long, forceRefresh: Boolean): DashboardNextModuleItem? { + val params = RestParams(isForceReadFromNetwork = forceRefresh) val modules = moduleApi.getFirstPageModulesWithItems( CanvasContext.Type.COURSE.apiString, courseId, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt index aaea7ad793..0dee36635a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt @@ -23,7 +23,7 @@ class ProgramNetworkDataSource @Inject constructor( private val getProgramsManager: GetProgramsManager, ) { - suspend fun getPrograms(): List { - return getProgramsManager.getPrograms(forceNetwork = true) + suspend fun getPrograms(forceRefresh: Boolean): List { + return getProgramsManager.getPrograms(forceNetwork = forceRefresh) } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt index f68048afbd..2e609258c3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt @@ -30,9 +30,9 @@ class CourseEnrollmentRepository @Inject constructor( featureFlagProvider: FeatureFlagProvider, ) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { - suspend fun getEnrollments(): List { + suspend fun getEnrollments(forceRefresh: Boolean = false): List { return if (shouldFetchFromNetwork()) { - networkDataSource.getEnrollments() + networkDataSource.getEnrollments(forceRefresh) .also { if (shouldSync()) localDataSource.saveEnrollments(it) } } else { localDataSource.getEnrollments() diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnLearningLibraryRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnLearningLibraryRepository.kt new file mode 100644 index 0000000000..387df0528d --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnLearningLibraryRepository.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse +import com.instructure.horizon.data.datasource.LearnLearningLibraryLocalDataSource +import com.instructure.horizon.data.datasource.LearnLearningLibraryNetworkDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class LearnLearningLibraryRepository @Inject constructor( + private val networkDataSource: LearnLearningLibraryNetworkDataSource, + private val localDataSource: LearnLearningLibraryLocalDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getEnrolledLearningLibraries(limit: Int, forceRefresh: Boolean = false): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getEnrolledLearningLibraries(limit, forceRefresh) + .also { if (shouldSync()) localDataSource.saveEnrolledLearningLibraries(it) } + } else { + localDataSource.getEnrolledLearningLibraries() + } + } + + suspend fun getLearningLibraryItems( + cursor: String?, + limit: Int?, + searchQuery: String?, + typeFilter: CollectionItemType?, + bookmarkedOnly: Boolean, + completedOnly: Boolean, + sortBy: CollectionItemSortOption?, + forceRefresh: Boolean = false, + ): LearningLibraryCollectionItemsResponse { + return if (shouldFetchFromNetwork()) { + networkDataSource.getLearningLibraryItems(cursor, limit, searchQuery, typeFilter, bookmarkedOnly, completedOnly, sortBy, forceRefresh) + .also { response -> + if (shouldSync() && cursor == null && bookmarkedOnly) { + localDataSource.saveSavedItems(response.items) + } + } + } else { + localDataSource.getSavedItems() + } + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnMyContentRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnMyContentRepository.kt new file mode 100644 index 0000000000..e07f8171ca --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnMyContentRepository.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse +import com.instructure.horizon.data.datasource.LearnMyContentLocalDataSource +import com.instructure.horizon.data.datasource.LearnMyContentNetworkDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class LearnMyContentRepository @Inject constructor( + private val networkDataSource: LearnMyContentNetworkDataSource, + private val localDataSource: LearnMyContentLocalDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getLearnItems( + cursor: String?, + searchQuery: String?, + sortBy: CollectionItemSortOption?, + status: List?, + itemTypes: List?, + queryKey: String, + forceRefresh: Boolean = false, + ): LearnItemsResponse { + return if (shouldFetchFromNetwork()) { + networkDataSource.getLearnItems(cursor, searchQuery, sortBy, status, itemTypes, forceRefresh) + .also { response -> + if (shouldSync() && cursor == null) { + localDataSource.saveLearnItems(response.items, queryKey) + } + } + } else { + localDataSource.getLearnItems(queryKey) + } + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt index e7127d6caf..c0142b008c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt @@ -30,9 +30,9 @@ class ModuleItemRepository @Inject constructor( featureFlagProvider: FeatureFlagProvider, ) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { - suspend fun getNextModuleItemForCourse(courseId: Long): DashboardNextModuleItem? { + suspend fun getNextModuleItemForCourse(courseId: Long, forceRefresh: Boolean = false): DashboardNextModuleItem? { return if (shouldFetchFromNetwork()) { - networkDataSource.getNextModuleItemForCourse(courseId) + networkDataSource.getNextModuleItemForCourse(courseId, forceRefresh) .also { item -> if (shouldSync() && item != null) localDataSource.saveNextModuleItem(item) } } else { localDataSource.getNextModuleItemForCourse(courseId) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt index 5ac37c4f6e..422a9a970c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt @@ -31,9 +31,9 @@ class ProgramRepository @Inject constructor( featureFlagProvider: FeatureFlagProvider, ) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { - suspend fun getPrograms(): List { + suspend fun getPrograms(forceRefresh: Boolean = false): List { return if (shouldFetchFromNetwork()) { - networkDataSource.getPrograms() + networkDataSource.getPrograms(forceRefresh) .also { programs -> if (shouldSync()) { val enrolledCourseIds = enrollmentRepository.getEnrolledCourseIds().toSet() diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt index fca3d4a40e..17e5efa323 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt @@ -21,11 +21,18 @@ 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.HorizonLearnCollectionDao +import com.instructure.horizon.database.dao.HorizonLearnItemDao +import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao 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.HorizonLearnCollectionEntity +import com.instructure.horizon.database.entity.HorizonLearnCollectionItemEntity +import com.instructure.horizon.database.entity.HorizonLearnItemEntity +import com.instructure.horizon.database.entity.HorizonLearnSavedItemEntity import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity @TypeConverters(HorizonTypeConverters::class) @@ -36,12 +43,19 @@ import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity HorizonDashboardProgramCourseRef::class, HorizonDashboardModuleItemEntity::class, HorizonSyncMetadataEntity::class, + HorizonLearnItemEntity::class, + HorizonLearnCollectionEntity::class, + HorizonLearnCollectionItemEntity::class, + HorizonLearnSavedItemEntity::class, ], - version = 2, + version = 3, ) abstract class HorizonDatabase : RoomDatabase() { abstract fun dashboardEnrollmentDao(): HorizonDashboardEnrollmentDao abstract fun dashboardProgramDao(): HorizonDashboardProgramDao abstract fun dashboardModuleItemDao(): HorizonDashboardModuleItemDao abstract fun syncMetadataDao(): HorizonSyncMetadataDao + abstract fun learnItemDao(): HorizonLearnItemDao + abstract fun learnCollectionDao(): HorizonLearnCollectionDao + abstract fun learnSavedItemDao(): HorizonLearnSavedItemDao } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCollectionDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCollectionDao.kt new file mode 100644 index 0000000000..77207f4329 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCollectionDao.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.instructure.horizon.database.entity.HorizonLearnCollectionEntity +import com.instructure.horizon.database.entity.HorizonLearnCollectionItemEntity + +@Dao +interface HorizonLearnCollectionDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCollections(collections: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItems(items: List) + + @Query("SELECT * FROM horizon_learn_collections") + suspend fun getAllCollections(): List + + @Query("SELECT * FROM horizon_learn_collection_items WHERE collectionId = :collectionId") + suspend fun getItemsByCollectionId(collectionId: String): List + + @Query("DELETE FROM horizon_learn_collections") + suspend fun deleteAllCollections() + + @Query("DELETE FROM horizon_learn_collection_items") + suspend fun deleteAllItems() + + @Transaction + suspend fun replaceAll( + collections: List, + items: List, + ) { + deleteAllCollections() + deleteAllItems() + insertCollections(collections) + insertItems(items) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnItemDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnItemDao.kt new file mode 100644 index 0000000000..988ca2b613 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnItemDao.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.instructure.horizon.database.entity.HorizonLearnItemEntity + +@Dao +interface HorizonLearnItemDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List) + + @Query("SELECT * FROM horizon_learn_items WHERE queryKey = :queryKey") + suspend fun getByQueryKey(queryKey: String): List + + @Query("DELETE FROM horizon_learn_items WHERE queryKey = :queryKey") + suspend fun deleteByQueryKey(queryKey: String) + + @Transaction + suspend fun replaceByQueryKey(entities: List, queryKey: String) { + deleteByQueryKey(queryKey) + insertAll(entities) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnSavedItemDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnSavedItemDao.kt new file mode 100644 index 0000000000..e7824a5536 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnSavedItemDao.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.instructure.horizon.database.entity.HorizonLearnSavedItemEntity + +@Dao +interface HorizonLearnSavedItemDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List) + + @Query("SELECT * FROM horizon_learn_saved_items") + suspend fun getAll(): List + + @Query("DELETE FROM horizon_learn_saved_items") + suspend fun deleteAll() + + @Transaction + suspend fun replaceAll(entities: List) { + deleteAll() + insertAll(entities) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCollectionEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCollectionEntity.kt new file mode 100644 index 0000000000..1cdafb8ac2 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCollectionEntity.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "horizon_learn_collections") +data class HorizonLearnCollectionEntity( + @PrimaryKey val id: String, + val name: String, + val publicName: String?, + val description: String?, + val createdAtMs: Long, + val updatedAtMs: Long, + val totalItemCount: Int, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCollectionItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCollectionItemEntity.kt new file mode 100644 index 0000000000..071a2c6cb4 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCollectionItemEntity.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Flattened entity for [com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem] + * belonging to an enrolled collection (Browse tab). + */ +@Entity( + tableName = "horizon_learn_collection_items", + indices = [Index("collectionId")] +) +data class HorizonLearnCollectionItemEntity( + @PrimaryKey val id: String, + val collectionId: String, + val libraryId: String, + val itemType: String, + val displayOrder: Double, + // CanvasCourseInfo flattened + val canvasCourseId: String?, + val canvasUrl: String?, + val courseName: String?, + val courseImageUrl: String?, + val moduleCount: Double?, + val moduleItemCount: Double?, + val estimatedDurationMinutes: Double?, + // LearningLibraryModuleInfo flattened + val moduleId: String?, + val moduleItemId: String?, + val moduleItemType: String?, + val resourceId: String?, + // Other + val programId: String?, + val programCourseId: String?, + val createdAtMs: Long, + val updatedAtMs: Long, + val isBookmarked: Boolean, + val completionPercentage: Double?, + val isEnrolledInCanvas: Boolean?, + val canvasEnrollmentId: String?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnItemEntity.kt new file mode 100644 index 0000000000..299045e375 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnItemEntity.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Flattened entity for both [com.instructure.canvasapi2.models.journey.mycontent.ProgramEnrollmentItem] + * and [com.instructure.canvasapi2.models.journey.mycontent.CourseEnrollmentItem]. + * [queryKey] identifies which fetch bucket this item belongs to (e.g. "IN_PROGRESS" or "COMPLETED"). + */ +@Entity( + tableName = "horizon_learn_items", + indices = [Index("queryKey")] +) +data class HorizonLearnItemEntity( + @PrimaryKey val id: String, + val queryKey: String, + val itemType: String, + val name: String, + val position: Int, + val enrolledAtMs: Long?, + val completionPercentage: Double?, + // ProgramEnrollmentItem-specific fields + val startDateMs: Long?, + val endDateMs: Long?, + val enrollmentStatus: String?, + val description: String?, + val variant: String?, + val estimatedDurationMinutes: Int?, + val courseCount: Int?, + // CourseEnrollmentItem-specific fields + val startAtMs: Long?, + val endAtMs: Long?, + val requirementCount: Int?, + val requirementCompletedCount: Int?, + val completedAtMs: Long?, + val grade: Double?, + val imageUrl: String?, + val workflowState: String?, + val lastActivityAtMs: Long?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnSavedItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnSavedItemEntity.kt new file mode 100644 index 0000000000..40a3e742ba --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnSavedItemEntity.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Flattened entity for bookmarked [com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem] + * items (Saved tab). + */ +@Entity(tableName = "horizon_learn_saved_items") +data class HorizonLearnSavedItemEntity( + @PrimaryKey val id: String, + val libraryId: String, + val itemType: String, + val displayOrder: Double, + // CanvasCourseInfo flattened + val canvasCourseId: String?, + val canvasUrl: String?, + val courseName: String?, + val courseImageUrl: String?, + val moduleCount: Double?, + val moduleItemCount: Double?, + val estimatedDurationMinutes: Double?, + // LearningLibraryModuleInfo flattened + val moduleId: String?, + val moduleItemId: String?, + val moduleItemType: String?, + val resourceId: String?, + // Other + val programId: String?, + val programCourseId: String?, + val createdAtMs: Long, + val updatedAtMs: Long, + val isBookmarked: Boolean, + val completionPercentage: Double?, + val isEnrolledInCanvas: Boolean?, + val canvasEnrollmentId: String?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt index 852461480c..c77b761936 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt @@ -22,6 +22,9 @@ enum class SyncDataType { DASHBOARD_ENROLLMENTS, DASHBOARD_PROGRAMS, DASHBOARD_MODULE_ITEMS, + LEARN_MY_CONTENT_ITEMS, + LEARN_SAVED_ITEMS, + LEARN_LIBRARY_COLLECTIONS, } @Entity(tableName = "horizon_sync_metadata") diff --git a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt index 99124f89d8..30755cd1e4 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt @@ -21,6 +21,9 @@ 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.HorizonLearnCollectionDao +import com.instructure.horizon.database.dao.HorizonLearnItemDao +import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao import com.instructure.horizon.database.dao.HorizonSyncMetadataDao import dagger.Module import dagger.Provides @@ -59,4 +62,19 @@ class HorizonOfflineModule { fun provideHorizonSyncMetadataDao(db: HorizonDatabase): HorizonSyncMetadataDao { return db.syncMetadataDao() } + + @Provides + fun provideHorizonLearnItemDao(db: HorizonDatabase): HorizonLearnItemDao { + return db.learnItemDao() + } + + @Provides + fun provideHorizonLearnCollectionDao(db: HorizonDatabase): HorizonLearnCollectionDao { + return db.learnCollectionDao() + } + + @Provides + fun provideHorizonLearnSavedItemDao(db: HorizonDatabase): HorizonLearnSavedItemDao { + return db.learnSavedItemDao() + } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibrariesUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibrariesUseCase.kt new file mode 100644 index 0000000000..88a2604a81 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibrariesUseCase.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection +import com.instructure.horizon.data.repository.LearnLearningLibraryRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetLearnLearningLibrariesUseCase @Inject constructor( + private val repository: LearnLearningLibraryRepository, +) : BaseUseCase>() { + + override suspend fun execute(params: Int): List { + return repository.getEnrolledLearningLibraries(params) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibraryItemsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibraryItemsUseCase.kt new file mode 100644 index 0000000000..1da5916ad3 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibraryItemsUseCase.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse +import com.instructure.horizon.data.repository.LearnLearningLibraryRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +data class GetLearnLearningLibraryItemsParams( + val cursor: String?, + val limit: Int?, + val searchQuery: String?, + val typeFilter: CollectionItemType?, + val bookmarkedOnly: Boolean, + val completedOnly: Boolean, + val sortBy: CollectionItemSortOption?, + val forceRefresh: Boolean = false, +) + +class GetLearnLearningLibraryItemsUseCase @Inject constructor( + private val repository: LearnLearningLibraryRepository, +) : BaseUseCase() { + + override suspend fun execute(params: GetLearnLearningLibraryItemsParams): LearningLibraryCollectionItemsResponse { + return repository.getLearningLibraryItems( + cursor = params.cursor, + limit = params.limit, + searchQuery = params.searchQuery, + typeFilter = params.typeFilter, + bookmarkedOnly = params.bookmarkedOnly, + completedOnly = params.completedOnly, + sortBy = params.sortBy, + forceRefresh = params.forceRefresh, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibraryRecommendationsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibraryRecommendationsUseCase.kt new file mode 100644 index 0000000000..6dcb6e2765 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibraryRecommendationsUseCase.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryRecommendation +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +data class GetLearnLearningLibraryRecommendationsParams( + val forceRefresh: Boolean = false, +) + +class GetLearnLearningLibraryRecommendationsUseCase @Inject constructor( + private val getLearningLibraryManager: GetLearningLibraryManager, +) : BaseUseCase>() { + + override suspend fun execute(params: GetLearnLearningLibraryRecommendationsParams): List { + return getLearningLibraryManager.getLearningLibraryRecommendations(forceNetwork = params.forceRefresh) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnMyContentCompletedItemsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnMyContentCompletedItemsUseCase.kt new file mode 100644 index 0000000000..8a325703b5 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnMyContentCompletedItemsUseCase.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse +import com.instructure.horizon.data.repository.LearnMyContentRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +private const val QUERY_KEY = "COMPLETED" + +data class GetLearnMyContentCompletedItemsParams( + val cursor: String?, + val searchQuery: String?, + val sortBy: CollectionItemSortOption?, + val itemTypes: List?, + val forceRefresh: Boolean = false, +) + +class GetLearnMyContentCompletedItemsUseCase @Inject constructor( + private val repository: LearnMyContentRepository, +) : BaseUseCase() { + + override suspend fun execute(params: GetLearnMyContentCompletedItemsParams): LearnItemsResponse { + return repository.getLearnItems( + cursor = params.cursor, + searchQuery = params.searchQuery, + sortBy = params.sortBy, + status = listOf(LearnItemStatus.COMPLETED), + itemTypes = params.itemTypes, + queryKey = QUERY_KEY, + forceRefresh = params.forceRefresh, + ) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnMyContentInProgressItemsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnMyContentInProgressItemsUseCase.kt new file mode 100644 index 0000000000..e7485b7242 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnMyContentInProgressItemsUseCase.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse +import com.instructure.horizon.data.repository.LearnMyContentRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +private const val QUERY_KEY = "IN_PROGRESS" + +data class GetLearnMyContentInProgressItemsParams( + val cursor: String?, + val searchQuery: String?, + val sortBy: CollectionItemSortOption?, + val itemTypes: List?, + val forceRefresh: Boolean = false, +) + +class GetLearnMyContentInProgressItemsUseCase @Inject constructor( + private val repository: LearnMyContentRepository, +) : BaseUseCase() { + + override suspend fun execute(params: GetLearnMyContentInProgressItemsParams): LearnItemsResponse { + return repository.getLearnItems( + cursor = params.cursor, + searchQuery = params.searchQuery, + sortBy = params.sortBy, + status = listOf(LearnItemStatus.IN_PROGRESS, LearnItemStatus.NOT_STARTED), + itemTypes = params.itemTypes, + queryKey = QUERY_KEY, + forceRefresh = params.forceRefresh, + ) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/ToggleLearnLearningLibraryItemBookmarkUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/ToggleLearnLearningLibraryItemBookmarkUseCase.kt new file mode 100644 index 0000000000..622716b07b --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/ToggleLearnLearningLibraryItemBookmarkUseCase.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +data class ToggleLearnLearningLibraryItemBookmarkParams( + val itemId: String, +) + +class ToggleLearnLearningLibraryItemBookmarkUseCase @Inject constructor( + private val getLearningLibraryManager: GetLearningLibraryManager, +) : BaseUseCase() { + + override suspend fun execute(params: ToggleLearnLearningLibraryItemBookmarkParams): Boolean { + return getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked(params.itemId) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnRepository.kt index c4e1e45dc1..2380aaa877 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnRepository.kt @@ -15,14 +15,16 @@ */ package com.instructure.horizon.features.learn -import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection +import com.instructure.horizon.data.repository.LearnLearningLibraryRepository import javax.inject.Inject class LearnRepository @Inject constructor( - private val getLearningLibraryManager: GetLearningLibraryManager, + private val learningLibraryRepository: LearnLearningLibraryRepository, ) { - suspend fun getEnrolledLearningLibraries(forceNetwork: Boolean): List { - return getLearningLibraryManager.getEnrolledLearningLibraryCollections(4, forceNetwork).collections + private val itemLimitPerCollection = 4 + + suspend fun getEnrolledLearningLibraries(): List { + return learningLibraryRepository.getEnrolledLearningLibraries(itemLimitPerCollection) } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt index 00235f5377..99183c1ac9 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt @@ -15,10 +15,12 @@ */ package com.instructure.horizon.features.learn -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -28,7 +30,10 @@ import javax.inject.Inject @HiltViewModel class LearnViewModel @Inject constructor( private val repository: LearnRepository, -) : ViewModel() { + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { + private val _uiState = MutableStateFlow(LearnUiState( updateSelectedTab = ::updateSelectedTab, updateSelectedTabIndex = ::updateSelectedTabIndex, @@ -37,10 +42,30 @@ class LearnViewModel @Inject constructor( val state = _uiState.asStateFlow() init { + loadBrowseTab() + } + + override fun onNetworkRestored() { + loadBrowseTab() + } + + override fun onNetworkLost() { + // Offline banner is handled at the screen level; no action needed here + } + + private fun loadBrowseTab() { viewModelScope.tryLaunch { - val enrolledLearningLibraries = repository.getEnrolledLearningLibraries(false) - if (enrolledLearningLibraries.isNotEmpty()) { - _uiState.update { it.copy(tabs = it.tabs + LearnTab.BROWSE) } + val enrolledLearningLibraries = repository.getEnrolledLearningLibraries() + val hasBrowseTab = enrolledLearningLibraries.isNotEmpty() + _uiState.update { current -> + val tabs = if (hasBrowseTab && LearnTab.BROWSE !in current.tabs) { + current.tabs + LearnTab.BROWSE + } else if (!hasBrowseTab) { + current.tabs - LearnTab.BROWSE + } else { + current.tabs + } + current.copy(tabs = tabs) } } catch { } } @@ -58,4 +83,4 @@ class LearnViewModel @Inject constructor( ) } } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepository.kt index add6321fd0..d4a18a2c1f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepository.kt @@ -22,9 +22,11 @@ import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemT import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryRecommendation +import com.instructure.horizon.data.repository.LearnLearningLibraryRepository import javax.inject.Inject class LearnLearningLibraryListRepository @Inject constructor( + private val learningLibraryRepository: LearnLearningLibraryRepository, private val getLearningLibraryManager: GetLearningLibraryManager, ) { private val itemLimitPerCollection = 4 @@ -37,29 +39,29 @@ class LearnLearningLibraryListRepository @Inject constructor( bookmarkedOnly: Boolean = false, completedOnly: Boolean = false, sortBy: CollectionItemSortOption? = null, - forceNetwork: Boolean + forceRefresh: Boolean = false, ): LearningLibraryCollectionItemsResponse { - return getLearningLibraryManager.getLearningLibraryCollectionItems( + return learningLibraryRepository.getLearningLibraryItems( cursor = afterCursor, limit = limit, + searchQuery = searchQuery, + typeFilter = typeFilter, bookmarkedOnly = bookmarkedOnly, completedOnly = completedOnly, - searchTerm = searchQuery, - types = typeFilter?.let { listOf(it) }, sortBy = sortBy, - forceNetwork = forceNetwork + forceRefresh = forceRefresh, ) } - suspend fun getEnrolledLearningLibraries(forceNetwork: Boolean): List { - return getLearningLibraryManager.getEnrolledLearningLibraryCollections(itemLimitPerCollection, forceNetwork).collections + suspend fun getEnrolledLearningLibraries(forceRefresh: Boolean = false): List { + return learningLibraryRepository.getEnrolledLearningLibraries(itemLimitPerCollection, forceRefresh) } suspend fun toggleLearningLibraryItemIsBookmarked(itemId: String): Boolean { return getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked(itemId) } - suspend fun getLearningLibraryRecommendedItems(forceNetwork: Boolean): List { - return getLearningLibraryManager.getLearningLibraryRecommendations(forceNetwork) + suspend fun getLearningLibraryRecommendedItems(forceRefresh: Boolean = false): List { + return getLearningLibraryManager.getLearningLibraryRecommendations(forceNetwork = forceRefresh) } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModel.kt index 29fad0657c..dabc361bc6 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModel.kt @@ -18,7 +18,6 @@ package com.instructure.horizon.features.learn.learninglibrary.list import android.content.res.Resources import androidx.compose.ui.text.input.TextFieldValue -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType @@ -36,6 +35,9 @@ import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearni import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter import com.instructure.horizon.features.learn.learninglibrary.common.toUiState import com.instructure.horizon.horizonui.platform.LoadingState +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow @@ -54,7 +56,9 @@ class LearnLearningLibraryListViewModel @Inject constructor( private val repository: LearnLearningLibraryListRepository, private val eventHandler: LearnEventHandler, private val apiPrefs: ApiPrefs, -): ViewModel() { + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { private var currentTypeFilter: LearnLearningLibraryTypeFilter = LearnLearningLibraryTypeFilter.All private var currentSortOption: LearnLearningLibrarySortOption = LearnLearningLibrarySortOption.MostRecent @@ -132,8 +136,16 @@ class LearnLearningLibraryListViewModel @Inject constructor( } } - private suspend fun fetchRecommendedItems(forceNetwork: Boolean = false): List { - val recommendations = repository.getLearningLibraryRecommendedItems(forceNetwork) + override fun onNetworkRestored() { + refreshCollections() + } + + override fun onNetworkLost() { + // Offline banner is handled at the screen level; no action needed here + } + + private suspend fun fetchRecommendedItems(forceRefresh: Boolean = false): List { + val recommendations = repository.getLearningLibraryRecommendedItems(forceRefresh) _uiState.update { it.copy(collectionState = it.collectionState.copy( recommendedItems = recommendations.map { it.toUiState(resources) } @@ -145,8 +157,8 @@ class LearnLearningLibraryListViewModel @Inject constructor( private fun loadCollections() { viewModelScope.tryLaunch { _uiState.update { it.copy(collectionState = it.collectionState.copy(loadingState = it.collectionState.loadingState.copy(isLoading = true))) } - val result = fetchCollections() - val recommendedItems = fetchRecommendedItems() + val result = fetchCollections(forceRefresh = false) + val recommendedItems = fetchRecommendedItems(forceRefresh = false) allCollections = result.toUiState(resources, recommendedItems) _uiState.update { it.copy(collectionState = it.collectionState.copy(collections = allCollections)) } _uiState.update { it.copy(collectionState = it.collectionState.copy(loadingState = it.collectionState.loadingState.copy(isLoading = false))) } @@ -163,7 +175,7 @@ class LearnLearningLibraryListViewModel @Inject constructor( ) { viewModelScope.tryLaunch { _uiState.update { it.copy(itemState = it.itemState.copy(loadingState = it.itemState.loadingState.copy(isLoading = true))) } - fetchItems(cursor, searchQuery, typeFilter, sortBy = sortBy) + fetchItems(cursor, searchQuery, typeFilter, sortBy = sortBy, forceRefresh = false) _uiState.update { it.copy(itemState = it.itemState.copy(loadingState = it.itemState.loadingState.copy(isLoading = false))) } } catch { _uiState.update { it.copy(itemState = it.itemState.copy(loadingState = it.itemState.loadingState.copy(isLoading = false, isError = true))) } @@ -173,8 +185,8 @@ class LearnLearningLibraryListViewModel @Inject constructor( private fun refreshCollections() { viewModelScope.tryLaunch { _uiState.update { it.copy(collectionState = it.collectionState.copy(loadingState = it.collectionState.loadingState.copy(isRefreshing = true))) } - val result = fetchCollections(true) - val recommendedItems = fetchRecommendedItems(true) + val result = fetchCollections(forceRefresh = true) + val recommendedItems = fetchRecommendedItems(forceRefresh = true) allCollections = result.toUiState(resources, recommendedItems) _uiState.update { it.copy(collectionState = it.collectionState.copy(collections = allCollections)) } _uiState.update { it.copy(collectionState = it.collectionState.copy(loadingState = it.collectionState.loadingState.copy(isRefreshing = false, isError = false))) } @@ -185,8 +197,8 @@ class LearnLearningLibraryListViewModel @Inject constructor( } } - private suspend fun fetchCollections(forceNetwork: Boolean = false): List { - return repository.getEnrolledLearningLibraries(forceNetwork) + private suspend fun fetchCollections(forceRefresh: Boolean = false): List { + return repository.getEnrolledLearningLibraries(forceRefresh) } private suspend fun fetchItems( @@ -194,7 +206,7 @@ class LearnLearningLibraryListViewModel @Inject constructor( searchQuery: String? = uiState.value.searchQuery.text, filterType: CollectionItemType? = currentTypeFilter.toCollectionItemType(), sortBy: CollectionItemSortOption? = currentSortOption.toCollectionItemSortOption(), - forceNetwork: Boolean = false + forceRefresh: Boolean = false, ) { val response = repository.getLearningLibraryItems( afterCursor = cursor, @@ -202,7 +214,7 @@ class LearnLearningLibraryListViewModel @Inject constructor( searchQuery = searchQuery, typeFilter = filterType, sortBy = sortBy, - forceNetwork = forceNetwork + forceRefresh = forceRefresh, ) val recommendedItemsList = fetchRecommendedItems() @@ -229,7 +241,7 @@ class LearnLearningLibraryListViewModel @Inject constructor( private fun refreshItems() { viewModelScope.tryLaunch { _uiState.update { it.copy(itemState = it.itemState.copy(loadingState = it.itemState.loadingState.copy(isRefreshing = true))) } - fetchItems(cursor = null, forceNetwork = true) + fetchItems(cursor = null, forceRefresh = true) _uiState.update { it.copy(itemState = it.itemState.copy(loadingState = it.itemState.loadingState.copy(isRefreshing = false, isError = false))) } } catch { _uiState.update { it.copy(itemState = it.itemState.copy(loadingState = it.itemState.loadingState.copy(isRefreshing = false, snackbarMessage = resources.getString( @@ -244,20 +256,14 @@ class LearnLearningLibraryListViewModel @Inject constructor( collections = it.collectionState.collections.map { collectionState -> collectionState.copy( items = collectionState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy(bookmarkLoading = true) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = true) + else collectionItemState } ) }, recommendedItems = it.collectionState.recommendedItems.map { recommendedItemState -> - if (recommendedItemState.id == itemId) { - recommendedItemState.copy(bookmarkLoading = true) - } else { - recommendedItemState - } + if (recommendedItemState.id == itemId) recommendedItemState.copy(bookmarkLoading = true) + else recommendedItemState } ))} @@ -267,26 +273,14 @@ class LearnLearningLibraryListViewModel @Inject constructor( collections = it.collectionState.collections.map { collectionState -> collectionState.copy( items = collectionState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy( - bookmarkLoading = false, - isBookmarked = newIsBookmarked - ) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = false, isBookmarked = newIsBookmarked) + else collectionItemState } ) }, recommendedItems = it.collectionState.recommendedItems.map { recommendedItemState -> - if (recommendedItemState.id == itemId) { - recommendedItemState.copy( - bookmarkLoading = false, - isBookmarked = newIsBookmarked - ) - } else { - recommendedItemState - } + if (recommendedItemState.id == itemId) recommendedItemState.copy(bookmarkLoading = false, isBookmarked = newIsBookmarked) + else recommendedItemState } ))} } catch { @@ -294,24 +288,14 @@ class LearnLearningLibraryListViewModel @Inject constructor( collections = it.collectionState.collections.map { collectionState -> collectionState.copy( items = collectionState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy( - bookmarkLoading = false, - ) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = false) + else collectionItemState } ) }, recommendedItems = it.collectionState.recommendedItems.map { recommendedItemState -> - if (recommendedItemState.id == itemId) { - recommendedItemState.copy( - bookmarkLoading = false, - ) - } else { - recommendedItemState - } + if (recommendedItemState.id == itemId) recommendedItemState.copy(bookmarkLoading = false) + else recommendedItemState }, loadingState = it.collectionState.loadingState.copy(snackbarMessage = resources.getString(R.string.learnLearningLibraryFailedToUpdateBookmarkMessage)))) } } @@ -323,33 +307,22 @@ class LearnLearningLibraryListViewModel @Inject constructor( it.copy( itemState = it.itemState.copy( items = it.itemState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy(bookmarkLoading = true) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = true) + else collectionItemState } ), collectionState = it.collectionState.copy( collections = it.collectionState.collections.map { collectionState -> collectionState.copy( items = collectionState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy( - bookmarkLoading = true, - ) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = true) + else collectionItemState } ) }, recommendedItems = it.collectionState.recommendedItems.map { recommendedItemState -> - if (recommendedItemState.id == itemId) { - recommendedItemState.copy(bookmarkLoading = true) - } else { - recommendedItemState - } + if (recommendedItemState.id == itemId) recommendedItemState.copy(bookmarkLoading = true) + else recommendedItemState } ) ) @@ -361,40 +334,22 @@ class LearnLearningLibraryListViewModel @Inject constructor( it.copy( itemState = it.itemState.copy( items = it.itemState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy( - isBookmarked = newIsBookmarked, - bookmarkLoading = false - ) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(isBookmarked = newIsBookmarked, bookmarkLoading = false) + else collectionItemState } ), collectionState = it.collectionState.copy( collections = it.collectionState.collections.map { collectionState -> collectionState.copy( items = collectionState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy( - bookmarkLoading = false, - isBookmarked = newIsBookmarked - ) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = false, isBookmarked = newIsBookmarked) + else collectionItemState } ) }, recommendedItems = it.collectionState.recommendedItems.map { recommendedItemState -> - if (recommendedItemState.id == itemId) { - recommendedItemState.copy( - bookmarkLoading = false, - isBookmarked = newIsBookmarked - ) - } else { - recommendedItemState - } + if (recommendedItemState.id == itemId) recommendedItemState.copy(bookmarkLoading = false, isBookmarked = newIsBookmarked) + else recommendedItemState } ) ) @@ -404,11 +359,8 @@ class LearnLearningLibraryListViewModel @Inject constructor( it.copy( itemState = it.itemState.copy( items = it.itemState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy(bookmarkLoading = false) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = false) + else collectionItemState }, loadingState = it.itemState.loadingState.copy(snackbarMessage = resources.getString(R.string.learnLearningLibraryFailedToUpdateBookmarkMessage)) ), @@ -416,22 +368,14 @@ class LearnLearningLibraryListViewModel @Inject constructor( collections = it.collectionState.collections.map { collectionState -> collectionState.copy( items = collectionState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy( - bookmarkLoading = false, - ) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = false) + else collectionItemState } ) }, recommendedItems = it.collectionState.recommendedItems.map { recommendedItemState -> - if (recommendedItemState.id == itemId) { - recommendedItemState.copy(bookmarkLoading = false) - } else { - recommendedItemState - } + if (recommendedItemState.id == itemId) recommendedItemState.copy(bookmarkLoading = false) + else recommendedItemState } ) ) @@ -468,4 +412,4 @@ class LearnLearningLibraryListViewModel @Inject constructor( private fun computeActiveFilterCount(): Int { return if (currentTypeFilter != LearnLearningLibraryTypeFilter.All) 1 else 0 } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentRepository.kt deleted file mode 100644 index fee8a47dd6..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentRepository.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.mycontent.common - -import com.instructure.canvasapi2.apis.ModuleAPI -import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager -import com.instructure.canvasapi2.managers.graphql.horizon.journey.MyContentManager -import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.ModuleObject -import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption -import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType -import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse -import javax.inject.Inject - -class LearnMyContentRepository @Inject constructor( - private val myContentManager: MyContentManager, - private val getLearningLibraryManager: GetLearningLibraryManager, - private val moduleApi: ModuleAPI.ModuleInterface, -) { - suspend fun getLearnItems( - cursor: String? = null, - searchQuery: String? = null, - sortBy: CollectionItemSortOption? = null, - status: List? = null, - itemTypes: List? = null, - forceNetwork: Boolean = false, - ): LearnItemsResponse { - return myContentManager.getLearnItems( - cursor = cursor, - searchTerm = searchQuery, - sortBy = sortBy, - status = status, - itemTypes = itemTypes, - forceNetwork = forceNetwork, - ) - } - - suspend fun getBookmarkedLearningLibraryItems( - afterCursor: String? = null, - limit: Int? = 10, - searchQuery: String? = null, - sortBy: CollectionItemSortOption? = null, - types: List? = null, - forceNetwork: Boolean = false, - ): LearningLibraryCollectionItemsResponse { - return getLearningLibraryManager.getLearningLibraryCollectionItems( - cursor = afterCursor, - limit = limit, - bookmarkedOnly = true, - completedOnly = false, - searchTerm = searchQuery, - types = types, - sortBy = sortBy, - forceNetwork = forceNetwork, - ) - } - - suspend fun getFirstPageModulesWithItems(courseId: Long, forceNetwork: Boolean): List { - val params = RestParams(isForceReadFromNetwork = forceNetwork) - return moduleApi.getFirstPageModulesWithItems( - CanvasContext.Type.COURSE.apiString, - courseId, - params, - includes = listOf("estimated_durations") - ).dataOrThrow - } -} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentViewModel.kt index 93bfd7a188..d625e85a8c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentViewModel.kt @@ -16,16 +16,19 @@ */ package com.instructure.horizon.features.learn.mycontent.common -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter import com.instructure.horizon.horizonui.platform.LoadingState import com.instructure.horizon.navigation.MainNavigationRoute +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -36,8 +39,10 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch abstract class LearnMyContentViewModel( - protected val repository: LearnMyContentRepository, -) : ViewModel() { + protected val getNextModuleItemUseCase: GetNextModuleItemUseCase, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { private data class Filters( val searchQuery: String = "", @@ -73,6 +78,14 @@ abstract class LearnMyContentViewModel( } } + override fun onNetworkRestored() { + refresh() + } + + override fun onNetworkLost() { + // Offline banner is handled at the screen level; no action needed here + } + fun onFiltersChanged(searchQuery: String, sortBy: LearnLearningLibrarySortOption, typeFilter: LearnLearningLibraryTypeFilter) { filtersFlow.tryEmit(Filters(searchQuery, sortBy, typeFilter)) } @@ -81,7 +94,7 @@ abstract class LearnMyContentViewModel( viewModelScope.tryLaunch { _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = true)) } nextCursor = null - fetchAndUpdate(cursor = null) + fetchAndUpdate(cursor = null, forceRefresh = false) _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = false, isError = false)) } } catch { _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = false, isError = true)) } @@ -92,7 +105,7 @@ abstract class LearnMyContentViewModel( viewModelScope.tryLaunch { _uiState.update { it.copy(loadingState = it.loadingState.copy(isRefreshing = true)) } nextCursor = null - fetchAndUpdate(cursor = null, forceNetwork = true) + fetchAndUpdate(cursor = null, forceRefresh = true) _uiState.update { it.copy(loadingState = it.loadingState.copy(isRefreshing = false, isError = false)) } } catch { _uiState.update { it.copy(loadingState = it.loadingState.copy(isRefreshing = false, snackbarMessage = errorMessage)) } @@ -102,20 +115,20 @@ abstract class LearnMyContentViewModel( private fun loadMore() { viewModelScope.tryLaunch { _uiState.update { it.copy(isMoreLoading = true) } - fetchAndUpdate(cursor = nextCursor, append = true) + fetchAndUpdate(cursor = nextCursor, forceRefresh = false, append = true) _uiState.update { it.copy(isMoreLoading = false) } } catch { _uiState.update { it.copy(isMoreLoading = false, loadingState = it.loadingState.copy(snackbarMessage = errorMessage)) } } } - private suspend fun fetchAndUpdate(cursor: String?, forceNetwork: Boolean = false, append: Boolean = false) { + private suspend fun fetchAndUpdate(cursor: String?, forceRefresh: Boolean = false, append: Boolean = false) { val (items, pageInfo) = fetchPage( cursor = cursor, searchQuery = currentFilters.searchQuery, sortBy = currentFilters.sortBy.toCollectionItemSortOption(), typeFilter = currentFilters.typeFilter, - forceNetwork = forceNetwork, + forceRefresh = forceRefresh, ) nextCursor = if (pageInfo.hasNextPage) pageInfo.nextCursor else null _uiState.update { state -> @@ -132,21 +145,13 @@ abstract class LearnMyContentViewModel( searchQuery: String, sortBy: CollectionItemSortOption, typeFilter: LearnLearningLibraryTypeFilter, - forceNetwork: Boolean, + forceRefresh: Boolean, ): Pair, LearningLibraryPageInfo> - protected suspend fun fetchNextModuleItemRoute(courseId: Long?, forceNetwork: Boolean): Any? { + protected suspend fun fetchNextModuleItemRoute(courseId: Long?): Any? { if (courseId == null) return null - val modules = repository.getFirstPageModulesWithItems(courseId, forceNetwork = forceNetwork) - val nextModuleItem = modules.flatMap { module -> module.items }.firstOrNull() - if (nextModuleItem == null) { - return null - } - - return MainNavigationRoute.ModuleItemSequence( - courseId, - nextModuleItem.id - ) + val nextModuleItem = getNextModuleItemUseCase(courseId) ?: return null + return MainNavigationRoute.ModuleItemSequence(courseId, nextModuleItem.moduleItemId) } private fun onSnackbarDismiss() { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModel.kt index 0e34569c21..bad8f0ceb5 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModel.kt @@ -19,22 +19,27 @@ package com.instructure.horizon.features.learn.mycontent.completed import android.content.res.Resources import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLearnMyContentCompletedItemsParams +import com.instructure.horizon.domain.usecase.GetLearnMyContentCompletedItemsUseCase +import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter import com.instructure.horizon.features.learn.mycontent.common.LearnContentCardState -import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentViewModel import com.instructure.horizon.features.learn.mycontent.common.toCardState +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class LearnMyContentCompletedViewModel @Inject constructor( private val resources: Resources, - repository: LearnMyContentRepository, -) : LearnMyContentViewModel(repository) { + private val getLearnMyContentCompletedItemsUseCase: GetLearnMyContentCompletedItemsUseCase, + getNextModuleItemUseCase: GetNextModuleItemUseCase, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : LearnMyContentViewModel(getNextModuleItemUseCase, networkStateProvider, featureFlagProvider) { override val errorMessage: String get() = resources.getString(R.string.learnMyContentProgramErrorMessage) @@ -44,21 +49,19 @@ class LearnMyContentCompletedViewModel @Inject constructor( searchQuery: String, sortBy: CollectionItemSortOption, typeFilter: LearnLearningLibraryTypeFilter, - forceNetwork: Boolean, + forceRefresh: Boolean, ): Pair, LearningLibraryPageInfo> { - val response = repository.getLearnItems( - cursor = cursor, - searchQuery = searchQuery.ifEmpty { null }, - sortBy = sortBy, - status = listOf(LearnItemStatus.COMPLETED), - itemTypes = typeFilter.toLearnItemType()?.let { listOf(it) }, - forceNetwork = forceNetwork, + val response = getLearnMyContentCompletedItemsUseCase( + GetLearnMyContentCompletedItemsParams( + cursor = cursor, + searchQuery = searchQuery.ifEmpty { null }, + sortBy = sortBy, + itemTypes = typeFilter.toLearnItemType()?.let { listOf(it) }, + forceRefresh = forceRefresh, + ) ) return response.items.map { - it.toCardState( - resources, - { fetchNextModuleItemRoute(it, forceNetwork) } - ) + it.toCardState(resources) { courseId -> fetchNextModuleItemRoute(courseId) } } to response.pageInfo } -} +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModel.kt index 23e2842d7b..c16a8243d0 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModel.kt @@ -19,21 +19,27 @@ package com.instructure.horizon.features.learn.mycontent.inprogress import android.content.res.Resources import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLearnMyContentInProgressItemsParams +import com.instructure.horizon.domain.usecase.GetLearnMyContentInProgressItemsUseCase +import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter import com.instructure.horizon.features.learn.mycontent.common.LearnContentCardState -import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentViewModel import com.instructure.horizon.features.learn.mycontent.common.toCardState +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class LearnMyContentInProgressViewModel @Inject constructor( private val resources: Resources, - repository: LearnMyContentRepository, -) : LearnMyContentViewModel(repository) { + private val getLearnMyContentInProgressItemsUseCase: GetLearnMyContentInProgressItemsUseCase, + getNextModuleItemUseCase: GetNextModuleItemUseCase, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : LearnMyContentViewModel(getNextModuleItemUseCase, networkStateProvider, featureFlagProvider) { override val errorMessage: String get() = resources.getString(R.string.learnMyContentProgramErrorMessage) @@ -43,21 +49,19 @@ class LearnMyContentInProgressViewModel @Inject constructor( searchQuery: String, sortBy: CollectionItemSortOption, typeFilter: LearnLearningLibraryTypeFilter, - forceNetwork: Boolean, + forceRefresh: Boolean, ): Pair, LearningLibraryPageInfo> { - val response = repository.getLearnItems( - cursor = cursor, - searchQuery = searchQuery.ifEmpty { null }, - sortBy = sortBy, - status = listOf(LearnItemStatus.IN_PROGRESS, LearnItemStatus.NOT_STARTED), - itemTypes = typeFilter.toLearnItemType()?.let { listOf(it) }, - forceNetwork = forceNetwork, + val response = getLearnMyContentInProgressItemsUseCase( + GetLearnMyContentInProgressItemsParams( + cursor = cursor, + searchQuery = searchQuery.ifEmpty { null }, + sortBy = sortBy, + itemTypes = typeFilter.toLearnItemType()?.let { listOf(it) }, + forceRefresh = forceRefresh, + ) ) return response.items.map { - it.toCardState( - resources, - { fetchNextModuleItemRoute(it, forceNetwork) } - ) + it.toCardState(resources) { courseId -> fetchNextModuleItemRoute(courseId) } } to response.pageInfo } -} +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedRepository.kt deleted file mode 100644 index 47b3a9ece1..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedRepository.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.mycontent.saved - -import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager -import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryRecommendation -import javax.inject.Inject - -class LearnMyContentSavedRepository @Inject constructor( - private val getLearningLibraryManager: GetLearningLibraryManager -) { - suspend fun toggleLearningLibraryItemIsBookmarked(itemId: String): Boolean { - return getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked(itemId) - } - - suspend fun getLearningLibraryRecommendedItems(forceNetwork: Boolean): List { - return getLearningLibraryManager.getLearningLibraryRecommendations(forceNetwork) - } -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModel.kt index 18ae6c6188..b3aeff2053 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModel.kt @@ -23,11 +23,19 @@ import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibrary import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryItemsParams +import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryItemsUseCase +import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryRecommendationsParams +import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryRecommendationsUseCase +import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase +import com.instructure.horizon.domain.usecase.ToggleLearnLearningLibraryItemBookmarkParams +import com.instructure.horizon.domain.usecase.ToggleLearnLearningLibraryItemBookmarkUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryCollectionItemState import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter import com.instructure.horizon.features.learn.learninglibrary.common.toUiState -import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.update import javax.inject.Inject @@ -35,9 +43,13 @@ import javax.inject.Inject @HiltViewModel class LearnMyContentSavedViewModel @Inject constructor( private val resources: Resources, - myContentRepository: LearnMyContentRepository, - private val savedContentRepository: LearnMyContentSavedRepository, -) : LearnMyContentViewModel(myContentRepository) { + private val getLearnLearningLibraryItemsUseCase: GetLearnLearningLibraryItemsUseCase, + private val getLearnLearningLibraryRecommendationsUseCase: GetLearnLearningLibraryRecommendationsUseCase, + private val toggleLearnLearningLibraryItemBookmarkUseCase: ToggleLearnLearningLibraryItemBookmarkUseCase, + getNextModuleItemUseCase: GetNextModuleItemUseCase, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : LearnMyContentViewModel(getNextModuleItemUseCase, networkStateProvider, featureFlagProvider) { override val errorMessage: String get() = resources.getString(R.string.learnMyContentProgramErrorMessage) @@ -47,42 +59,39 @@ class LearnMyContentSavedViewModel @Inject constructor( searchQuery: String, sortBy: CollectionItemSortOption, typeFilter: LearnLearningLibraryTypeFilter, - forceNetwork: Boolean, + forceRefresh: Boolean, ): Pair, LearningLibraryPageInfo> { - val recommendations = savedContentRepository.getLearningLibraryRecommendedItems(forceNetwork) - val response = repository.getBookmarkedLearningLibraryItems( - afterCursor = cursor, - searchQuery = searchQuery.ifEmpty { null }, - sortBy = sortBy, - types = typeFilter.toCollectionItemType()?.let { listOf(it) }, - forceNetwork = forceNetwork, + val recommendations = getLearnLearningLibraryRecommendationsUseCase(GetLearnLearningLibraryRecommendationsParams(forceRefresh)) + val response = getLearnLearningLibraryItemsUseCase( + GetLearnLearningLibraryItemsParams( + cursor = cursor, + limit = 10, + searchQuery = searchQuery.ifEmpty { null }, + typeFilter = typeFilter.toCollectionItemType(), + bookmarkedOnly = true, + completedOnly = false, + sortBy = sortBy, + forceRefresh = forceRefresh, + ) ) return response.items.map { it.toUiState(resources, recommendations) } to response.pageInfo } fun onBookmarkItem(itemId: String) { viewModelScope.tryLaunch { - _uiState.update { + _uiState.update { it.copy( contentCards = it.contentCards.map { itemState -> - if (itemState.id == itemId) { - itemState.copy(bookmarkLoading = true) - } else { - itemState - } + if (itemState.id == itemId) itemState.copy(bookmarkLoading = true) else itemState } ) } - savedContentRepository.toggleLearningLibraryItemIsBookmarked(itemId) + toggleLearnLearningLibraryItemBookmarkUseCase(ToggleLearnLearningLibraryItemBookmarkParams(itemId)) _uiState.update { it.copy( contentCards = it.contentCards.mapNotNull { itemState -> - if (itemState.id == itemId) { - null - } else { - itemState - } + if (itemState.id == itemId) null else itemState } ) } @@ -90,11 +99,7 @@ class LearnMyContentSavedViewModel @Inject constructor( _uiState.update { it.copy( contentCards = it.contentCards.map { itemState -> - if (itemState.id == itemId) { - itemState.copy(bookmarkLoading = false,) - } else { - itemState - } + if (itemState.id == itemId) itemState.copy(bookmarkLoading = false) else itemState }, loadingState = it.loadingState.copy(snackbarMessage = resources.getString(R.string.learnMyContentSavedFailedToBookmarkErrorMessage)) ) diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepositoryTest.kt index 627029aee7..9d87c83cd1 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepositoryTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepositoryTest.kt @@ -21,10 +21,10 @@ import com.instructure.canvasapi2.models.journey.learninglibrary.CanvasCourseInf import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection -import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollectionsResponse import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo +import com.instructure.horizon.data.repository.LearnLearningLibraryRepository import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -38,6 +38,7 @@ import java.util.Date class LearnLearningLibraryListRepositoryTest { private val getLearningLibraryManager: GetLearningLibraryManager = mockk(relaxed = true) + private val learningLibraryRepository: LearnLearningLibraryRepository = mockk(relaxed = true) private val testCollections = listOf( EnrolledLearningLibraryCollection( @@ -96,11 +97,8 @@ class LearnLearningLibraryListRepositoryTest { @Before fun setup() { - val response = EnrolledLearningLibraryCollectionsResponse( - collections = testCollections - ) - coEvery { getLearningLibraryManager.getEnrolledLearningLibraryCollections(any(), any()) } returns response - coEvery { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { learningLibraryRepository.getEnrolledLearningLibraries(any(), any()) } returns testCollections + coEvery { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( items = emptyList(), pageInfo = emptyPageInfo ) @@ -109,27 +107,27 @@ class LearnLearningLibraryListRepositoryTest { @Test fun `getEnrolledLearningLibraries returns list of collections`() = runTest { val repository = getRepository() - val result = repository.getEnrolledLearningLibraries(false) + val result = repository.getEnrolledLearningLibraries() assertEquals(2, result.size) assertEquals(testCollections, result) - coVerify { getLearningLibraryManager.getEnrolledLearningLibraryCollections(4, false) } + coVerify { learningLibraryRepository.getEnrolledLearningLibraries(4, false) } } @Test - fun `getEnrolledLearningLibraries with forceNetwork true calls API with force network`() = runTest { + fun `getEnrolledLearningLibraries with forceRefresh passes true to data layer`() = runTest { val repository = getRepository() - repository.getEnrolledLearningLibraries(true) - coVerify { getLearningLibraryManager.getEnrolledLearningLibraryCollections(4, true) } + repository.getEnrolledLearningLibraries(forceRefresh = true) + + coVerify { learningLibraryRepository.getEnrolledLearningLibraries(4, true) } } @Test fun `getEnrolledLearningLibraries returns empty list when no collections`() = runTest { - val emptyResponse = EnrolledLearningLibraryCollectionsResponse(collections = emptyList()) - coEvery { getLearningLibraryManager.getEnrolledLearningLibraryCollections(any(), any()) } returns emptyResponse + coEvery { learningLibraryRepository.getEnrolledLearningLibraries(any(), any()) } returns emptyList() val repository = getRepository() - val result = repository.getEnrolledLearningLibraries(false) + val result = repository.getEnrolledLearningLibraries() assertEquals(0, result.size) } @@ -137,79 +135,70 @@ class LearnLearningLibraryListRepositoryTest { @Test fun `getLearningLibraryItems returns items with no filters`() = runTest { val items = listOf(createTestCollectionItem("item1", "Python", "1", false, CollectionItemType.COURSE)) - coEvery { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( items = items, pageInfo = emptyPageInfo ) val repository = getRepository() - val result = repository.getLearningLibraryItems(forceNetwork = false) + val result = repository.getLearningLibraryItems() assertEquals(1, result.items.size) assertEquals(items, result.items) } @Test - fun `getLearningLibraryItems with cursor passes cursor to manager`() = runTest { + fun `getLearningLibraryItems with cursor passes cursor to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(afterCursor = "cursor123", forceNetwork = false) + repository.getLearningLibraryItems(afterCursor = "cursor123") - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(cursor = "cursor123", any(), any(), any(), any(), any(), any(), any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(cursor = "cursor123", any(), any(), any(), any(), any(), any(), any()) } } @Test - fun `getLearningLibraryItems with searchQuery passes searchTerm to manager`() = runTest { + fun `getLearningLibraryItems with searchQuery passes searchQuery to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(searchQuery = "python", forceNetwork = false) + repository.getLearningLibraryItems(searchQuery = "python") - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), searchTerm = "python", any(), any(), any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), searchQuery = "python", any(), any(), any(), any(), any()) } } @Test - fun `getLearningLibraryItems with typeFilter passes types to manager`() = runTest { + fun `getLearningLibraryItems with typeFilter passes typeFilter to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(typeFilter = CollectionItemType.COURSE, forceNetwork = false) + repository.getLearningLibraryItems(typeFilter = CollectionItemType.COURSE) - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), types = listOf(CollectionItemType.COURSE), any(), any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), typeFilter = CollectionItemType.COURSE, any(), any(), any(), any()) } } @Test - fun `getLearningLibraryItems with null typeFilter passes null types to manager`() = runTest { + fun `getLearningLibraryItems with null typeFilter passes null to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(typeFilter = null, forceNetwork = false) + repository.getLearningLibraryItems(typeFilter = null) - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), types = null, any(), any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), typeFilter = null, any(), any(), any(), any()) } } @Test - fun `getLearningLibraryItems with bookmarkedOnly passes bookmarkedOnly to manager`() = runTest { + fun `getLearningLibraryItems with bookmarkedOnly passes bookmarkedOnly to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(bookmarkedOnly = true, forceNetwork = false) + repository.getLearningLibraryItems(bookmarkedOnly = true) - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), bookmarkedOnly = true, any(), any(), any(), any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), bookmarkedOnly = true, any(), any(), any()) } } @Test - fun `getLearningLibraryItems with completedOnly passes completedOnly to manager`() = runTest { + fun `getLearningLibraryItems with completedOnly passes completedOnly to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(completedOnly = true, forceNetwork = false) + repository.getLearningLibraryItems(completedOnly = true) - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), completedOnly = true, any()) } - } - - @Test - fun `getLearningLibraryItems with forceNetwork true passes flag to manager`() = runTest { - val repository = getRepository() - - repository.getLearningLibraryItems(forceNetwork = true) - - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), forceNetwork = true) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), any(), completedOnly = true, any(), any()) } } @Test @@ -222,43 +211,52 @@ class LearnLearningLibraryListRepositoryTest { totalCount = 10, pageCursors = null ) - coEvery { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( items = emptyList(), pageInfo = pageInfo ) val repository = getRepository() - val result = repository.getLearningLibraryItems(forceNetwork = false) + val result = repository.getLearningLibraryItems() assertTrue(result.pageInfo.hasNextPage) assertEquals("next_cursor", result.pageInfo.nextCursor) } @Test - fun `getLearningLibraryItems with limit passes limit to manager`() = runTest { + fun `getLearningLibraryItems with limit passes limit to data layer`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryItems(limit = 5) + + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), limit = 5, any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `getLearningLibraryItems with sortBy passes sortBy to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(limit = 5, forceNetwork = false) + repository.getLearningLibraryItems(sortBy = CollectionItemSortOption.NAME_A_Z) - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), limit = 5, any(), any(), any(), any(), any(), any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), sortBy = CollectionItemSortOption.NAME_A_Z, any()) } } @Test - fun `getLearningLibraryItems with sortBy passes sortBy to manager`() = runTest { + fun `getLearningLibraryItems with null sortBy passes null sortBy to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(sortBy = CollectionItemSortOption.NAME_A_Z, forceNetwork = false) + repository.getLearningLibraryItems(sortBy = null) - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), sortBy = CollectionItemSortOption.NAME_A_Z, any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), sortBy = null, any()) } } @Test - fun `getLearningLibraryItems with null sortBy passes null sortBy to manager`() = runTest { + fun `getLearningLibraryItems with forceRefresh passes forceRefresh true to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(sortBy = null, forceNetwork = false) + repository.getLearningLibraryItems(forceRefresh = true) - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), sortBy = null, any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), forceRefresh = true) } } @Test @@ -281,8 +279,26 @@ class LearnLearningLibraryListRepositoryTest { coVerify { getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked("item2") } } + @Test + fun `getLearningLibraryRecommendedItems passes forceRefresh false to manager`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryRecommendedItems(forceRefresh = false) + + coVerify { getLearningLibraryManager.getLearningLibraryRecommendations(forceNetwork = false) } + } + + @Test + fun `getLearningLibraryRecommendedItems passes forceRefresh true to manager`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryRecommendedItems(forceRefresh = true) + + coVerify { getLearningLibraryManager.getLearningLibraryRecommendations(forceNetwork = true) } + } + private fun getRepository(): LearnLearningLibraryListRepository { - return LearnLearningLibraryListRepository(getLearningLibraryManager) + return LearnLearningLibraryListRepository(learningLibraryRepository, getLearningLibraryManager) } private fun createTestCollectionItem( diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt index 9ac3e76f58..f098df430f 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt @@ -32,6 +32,8 @@ import com.instructure.horizon.features.learn.LearnEventHandler import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryFilterScreenType import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.ThemePrefs import io.mockk.coEvery import io.mockk.coVerify @@ -60,6 +62,8 @@ class LearnLearningLibraryListViewModelTest { private val resources: Resources = mockk(relaxed = true) private val repository: LearnLearningLibraryListRepository = mockk(relaxed = true) private val apiPrefs: ApiPrefs = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val emptyItemsResponse = LearningLibraryCollectionItemsResponse( @@ -125,6 +129,7 @@ class LearnLearningLibraryListViewModelTest { coEvery { repository.getEnrolledLearningLibraries(any()) } returns testCollections coEvery { repository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns emptyItemsResponse + coEvery { featureFlagProvider.offlineEnabled() } returns false } @After @@ -306,11 +311,12 @@ class LearnLearningLibraryListViewModelTest { } @Test - fun `Refresh calls repository with forceNetwork true`() = runTest { + fun `Refresh re-fetches collections`() = runTest { val viewModel = getViewModel() viewModel.uiState.value.collectionState.loadingState.onRefresh() + coVerify { repository.getEnrolledLearningLibraries(false) } coVerify { repository.getEnrolledLearningLibraries(true) } } @@ -333,7 +339,7 @@ class LearnLearningLibraryListViewModelTest { ) ) ) - coEvery { repository.getEnrolledLearningLibraries(true) } returns updatedCollections + coEvery { repository.getEnrolledLearningLibraries(any()) } returns updatedCollections viewModel.uiState.value.collectionState.loadingState.onRefresh() @@ -346,7 +352,7 @@ class LearnLearningLibraryListViewModelTest { @Test fun `Refresh on error shows snackbar message`() = runTest { val viewModel = getViewModel() - coEvery { repository.getEnrolledLearningLibraries(true) } throws Exception("Network error") + coEvery { repository.getEnrolledLearningLibraries(any()) } throws Exception("Network error") viewModel.uiState.value.collectionState.loadingState.onRefresh() @@ -358,7 +364,7 @@ class LearnLearningLibraryListViewModelTest { @Test fun `Dismiss snackbar clears both collection and item snackbar messages`() = runTest { val viewModel = getViewModel() - coEvery { repository.getEnrolledLearningLibraries(true) } throws Exception("Network error") + coEvery { repository.getEnrolledLearningLibraries(any()) } throws Exception("Network error") viewModel.uiState.value.collectionState.loadingState.onRefresh() viewModel.uiState.value.collectionState.loadingState.onSnackbarDismiss() @@ -676,7 +682,7 @@ class LearnLearningLibraryListViewModelTest { } @Test - fun `Items refresh calls repository with forceNetwork true`() = runTest { + fun `Items refresh re-fetches items`() = runTest { val viewModel = getViewModel() eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( screenType = LearnLearningLibraryFilterScreenType.Browse, @@ -686,6 +692,7 @@ class LearnLearningLibraryListViewModelTest { viewModel.uiState.value.itemState.loadingState.onRefresh() + coVerify { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), false) } coVerify { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), true) } } @@ -695,7 +702,7 @@ class LearnLearningLibraryListViewModelTest { val refreshedItems = listOf( createTestCollectionItem(id = "item1", courseName = "Refreshed Course") ) - coEvery { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), true) } returns LearningLibraryCollectionItemsResponse( + coEvery { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( items = refreshedItems, pageInfo = LearningLibraryPageInfo(null, null, false, false, null, null) ) @@ -711,7 +718,7 @@ class LearnLearningLibraryListViewModelTest { @Test fun `Items refresh on error shows snackbar message`() = runTest { val viewModel = getViewModel() - coEvery { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), true) } throws Exception("Network error") + coEvery { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), any()) } throws Exception("Network error") viewModel.uiState.value.itemState.loadingState.onRefresh() @@ -795,7 +802,7 @@ class LearnLearningLibraryListViewModelTest { } private fun getViewModel(): LearnLearningLibraryListViewModel { - return LearnLearningLibraryListViewModel(resources, repository, eventHandler, apiPrefs) + return LearnLearningLibraryListViewModel(resources, repository, eventHandler, apiPrefs, networkStateProvider, featureFlagProvider) } private fun createTestCollection( diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt index af64d41ba6..d56848aa17 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt @@ -19,14 +19,15 @@ package com.instructure.horizon.features.learn.mycontent.completed import android.content.res.Resources import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo -import com.instructure.canvasapi2.models.journey.mycontent.CourseEnrollmentItem -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse import com.instructure.canvasapi2.models.journey.mycontent.ProgramEnrollmentItem +import com.instructure.horizon.domain.usecase.GetLearnMyContentCompletedItemsUseCase +import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter -import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -50,7 +51,10 @@ import java.util.Date class LearnMyContentCompletedViewModelTest { private val resources: Resources = mockk(relaxed = true) - private val repository: LearnMyContentRepository = mockk(relaxed = true) + private val getLearnMyContentCompletedItemsUseCase: GetLearnMyContentCompletedItemsUseCase = mockk(relaxed = true) + private val getNextModuleItemUseCase: GetNextModuleItemUseCase = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val emptyResponse = LearnItemsResponse( @@ -64,8 +68,8 @@ class LearnMyContentCompletedViewModelTest { every { resources.getString(any()) } returns "" every { resources.getString(any(), *anyVararg()) } returns "" every { resources.getQuantityString(any(), any(), *anyVararg()) } returns "" - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns emptyResponse - coEvery { repository.getFirstPageModulesWithItems(any(), any()) } returns emptyList() + coEvery { getLearnMyContentCompletedItemsUseCase(any()) } returns emptyResponse + coEvery { featureFlagProvider.offlineEnabled() } returns false } @After @@ -74,38 +78,18 @@ class LearnMyContentCompletedViewModelTest { } @Test - fun `onFiltersChanged triggers load with COMPLETED status only`() = runTest { + fun `onFiltersChanged triggers load`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = CollectionItemSortOption.MOST_RECENT, - status = listOf(LearnItemStatus.COMPLETED), - itemTypes = null, - forceNetwork = false, - ) - } - } - - @Test - fun `onFiltersChanged does NOT pass IN_PROGRESS or NOT_STARTED status`() = runTest { - val viewModel = getViewModel() - - viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - - coVerify(exactly = 0) { - repository.getLearnItems( - status = match { it.contains(LearnItemStatus.IN_PROGRESS) || it.contains(LearnItemStatus.NOT_STARTED) }, - cursor = any(), - searchQuery = any(), - sortBy = any(), - itemTypes = any(), - forceNetwork = any(), - ) + getLearnMyContentCompletedItemsUseCase(match { + it.cursor == null && + it.searchQuery == null && + it.sortBy == CollectionItemSortOption.MOST_RECENT && + it.itemTypes == null + }) } } @@ -116,14 +100,11 @@ class LearnMyContentCompletedViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Programs) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = any(), - status = listOf(LearnItemStatus.COMPLETED), - itemTypes = listOf(LearnItemType.PROGRAM), - forceNetwork = false, - ) + getLearnMyContentCompletedItemsUseCase(match { + it.cursor == null && + it.sortBy == CollectionItemSortOption.MOST_RECENT && + it.itemTypes == listOf(LearnItemType.PROGRAM) + }) } } @@ -134,20 +115,17 @@ class LearnMyContentCompletedViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Courses) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = any(), - status = listOf(LearnItemStatus.COMPLETED), - itemTypes = listOf(LearnItemType.COURSE), - forceNetwork = false, - ) + getLearnMyContentCompletedItemsUseCase(match { + it.cursor == null && + it.sortBy == CollectionItemSortOption.MOST_RECENT && + it.itemTypes == listOf(LearnItemType.COURSE) + }) } } @Test fun `Successful load populates contentCards`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentCompletedItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem(name = "Completed Program")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -161,7 +139,7 @@ class LearnMyContentCompletedViewModelTest { @Test fun `Load error sets isError true`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } throws Exception("Network error") + coEvery { getLearnMyContentCompletedItemsUseCase(any()) } throws Exception("Network error") val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -171,7 +149,7 @@ class LearnMyContentCompletedViewModelTest { @Test fun `showMoreButton is true when pageInfo has next page`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentCompletedItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem()), pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 10, null) ) @@ -183,29 +161,21 @@ class LearnMyContentCompletedViewModelTest { } @Test - fun `Refresh calls repository with forceNetwork true`() = runTest { + fun `Refresh re-fetches items`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) viewModel.uiState.value.loadingState.onRefresh() - coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = any(), - status = listOf(LearnItemStatus.COMPLETED), - itemTypes = null, - forceNetwork = true, - ) - } + coVerify { getLearnMyContentCompletedItemsUseCase(match { !it.forceRefresh }) } + coVerify { getLearnMyContentCompletedItemsUseCase(match { it.forceRefresh }) } } @Test fun `Refresh error shows snackbar message`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), true) } throws Exception("Network error") + coEvery { getLearnMyContentCompletedItemsUseCase(any()) } throws Exception("Network error") viewModel.uiState.value.loadingState.onRefresh() @@ -223,8 +193,8 @@ class LearnMyContentCompletedViewModelTest { items = listOf(createTestProgramItem(id = "p2", name = "Second")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 2, null) ) - coEvery { repository.getLearnItems(null, any(), any(), any(), any(), any()) } returns firstPage - coEvery { repository.getLearnItems("cursor1", any(), any(), any(), any(), any()) } returns secondPage + coEvery { getLearnMyContentCompletedItemsUseCase(match { it.cursor == null }) } returns firstPage + coEvery { getLearnMyContentCompletedItemsUseCase(match { it.cursor == "cursor1" }) } returns secondPage val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -235,7 +205,9 @@ class LearnMyContentCompletedViewModelTest { assertEquals("Second", viewModel.uiState.value.contentCards[1].name) } - private fun getViewModel() = LearnMyContentCompletedViewModel(resources, repository) + private fun getViewModel() = LearnMyContentCompletedViewModel( + resources, getLearnMyContentCompletedItemsUseCase, getNextModuleItemUseCase, networkStateProvider, featureFlagProvider + ) private fun createTestProgramItem( id: String = "program1", @@ -254,4 +226,4 @@ class LearnMyContentCompletedViewModelTest { estimatedDurationMinutes = null, courseCount = 2, ) -} +} \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt index 16a561479a..c775bd96f9 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt @@ -17,19 +17,19 @@ package com.instructure.horizon.features.learn.mycontent.inprogress import android.content.res.Resources -import com.instructure.canvasapi2.models.ModuleItem -import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo import com.instructure.canvasapi2.models.journey.mycontent.CourseEnrollmentItem -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse import com.instructure.canvasapi2.models.journey.mycontent.ProgramEnrollmentItem import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLearnMyContentInProgressItemsUseCase +import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter -import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -55,7 +55,10 @@ import java.util.Date class LearnMyContentInProgressViewModelTest { private val resources: Resources = mockk(relaxed = true) - private val repository: LearnMyContentRepository = mockk(relaxed = true) + private val getLearnMyContentInProgressItemsUseCase: GetLearnMyContentInProgressItemsUseCase = mockk(relaxed = true) + private val getNextModuleItemUseCase: GetNextModuleItemUseCase = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val emptyResponse = LearnItemsResponse( @@ -69,8 +72,8 @@ class LearnMyContentInProgressViewModelTest { every { resources.getString(any()) } returns "" every { resources.getString(any(), *anyVararg()) } returns "" every { resources.getQuantityString(any(), any(), *anyVararg()) } returns "" - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns emptyResponse - coEvery { repository.getFirstPageModulesWithItems(any(), any()) } returns emptyList() + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns emptyResponse + coEvery { featureFlagProvider.offlineEnabled() } returns false } @After @@ -114,20 +117,18 @@ class LearnMyContentInProgressViewModelTest { } @Test - fun `onFiltersChanged triggers load with IN_PROGRESS and NOT_STARTED status`() = runTest { + fun `onFiltersChanged triggers load`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = CollectionItemSortOption.MOST_RECENT, - status = listOf(LearnItemStatus.IN_PROGRESS, LearnItemStatus.NOT_STARTED), - itemTypes = null, - forceNetwork = false, - ) + getLearnMyContentInProgressItemsUseCase(match { + it.cursor == null && + it.searchQuery == null && + it.sortBy == CollectionItemSortOption.MOST_RECENT && + it.itemTypes == null + }) } } @@ -138,14 +139,12 @@ class LearnMyContentInProgressViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Programs) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = CollectionItemSortOption.MOST_RECENT, - status = listOf(LearnItemStatus.IN_PROGRESS, LearnItemStatus.NOT_STARTED), - itemTypes = listOf(LearnItemType.PROGRAM), - forceNetwork = false, - ) + getLearnMyContentInProgressItemsUseCase(match { + it.cursor == null && + it.searchQuery == null && + it.sortBy == CollectionItemSortOption.MOST_RECENT && + it.itemTypes == listOf(LearnItemType.PROGRAM) + }) } } @@ -156,14 +155,12 @@ class LearnMyContentInProgressViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Courses) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = CollectionItemSortOption.MOST_RECENT, - status = listOf(LearnItemStatus.IN_PROGRESS, LearnItemStatus.NOT_STARTED), - itemTypes = listOf(LearnItemType.COURSE), - forceNetwork = false, - ) + getLearnMyContentInProgressItemsUseCase(match { + it.cursor == null && + it.searchQuery == null && + it.sortBy == CollectionItemSortOption.MOST_RECENT && + it.itemTypes == listOf(LearnItemType.COURSE) + }) } } @@ -174,14 +171,7 @@ class LearnMyContentInProgressViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = any(), - status = any(), - itemTypes = null, - forceNetwork = false, - ) + getLearnMyContentInProgressItemsUseCase(match { it.itemTypes == null }) } } @@ -192,21 +182,18 @@ class LearnMyContentInProgressViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.NameAscending, LearnLearningLibraryTypeFilter.All) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = CollectionItemSortOption.NAME_A_Z, - status = any(), - itemTypes = null, - forceNetwork = false, - ) + getLearnMyContentInProgressItemsUseCase(match { + it.cursor == null && + it.sortBy == CollectionItemSortOption.NAME_A_Z && + it.itemTypes == null + }) } } @Test fun `Successful load populates contentCards`() = runTest { val programs = listOf(createTestProgramItem(id = "p1", name = "Program A")) - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns LearnItemsResponse( items = programs, pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -220,7 +207,7 @@ class LearnMyContentInProgressViewModelTest { @Test fun `Successful load sets totalItemCount from pageInfo`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem()), pageInfo = LearningLibraryPageInfo(null, null, false, false, 42, null) ) @@ -233,7 +220,7 @@ class LearnMyContentInProgressViewModelTest { @Test fun `showMoreButton is true when pageInfo has next page`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem()), pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 10, null) ) @@ -246,7 +233,7 @@ class LearnMyContentInProgressViewModelTest { @Test fun `showMoreButton is false when pageInfo has no next page`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem()), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -259,7 +246,7 @@ class LearnMyContentInProgressViewModelTest { @Test fun `Load error sets isError true`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } throws Exception("Network error") + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } throws Exception("Network error") val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -270,14 +257,14 @@ class LearnMyContentInProgressViewModelTest { @Test fun `Filter change replaces existing items`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem(name = "First")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem(name = "Second")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -288,31 +275,23 @@ class LearnMyContentInProgressViewModelTest { } @Test - fun `Refresh calls repository with forceNetwork true`() = runTest { + fun `Refresh re-fetches items`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) viewModel.uiState.value.loadingState.onRefresh() - coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = any(), - status = any(), - itemTypes = null, - forceNetwork = true, - ) - } + coVerify { getLearnMyContentInProgressItemsUseCase(match { !it.forceRefresh }) } + coVerify { getLearnMyContentInProgressItemsUseCase(match { it.forceRefresh }) } } @Test fun `Refresh success clears error and updates content`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), false) } throws Exception("Error") + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } throws Exception("Error") val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), true) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem(name = "Refreshed")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -327,7 +306,7 @@ class LearnMyContentInProgressViewModelTest { fun `Refresh error shows snackbar message`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), true) } throws Exception("Network error") + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } throws Exception("Network error") viewModel.uiState.value.loadingState.onRefresh() @@ -339,7 +318,7 @@ class LearnMyContentInProgressViewModelTest { fun `Dismiss snackbar clears snackbar message`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), true) } throws Exception("Network error") + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } throws Exception("Network error") viewModel.uiState.value.loadingState.onRefresh() viewModel.uiState.value.loadingState.onSnackbarDismiss() @@ -357,8 +336,8 @@ class LearnMyContentInProgressViewModelTest { items = listOf(createTestProgramItem(id = "p2", name = "Second")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 2, null) ) - coEvery { repository.getLearnItems(null, any(), any(), any(), any(), any()) } returns firstPage - coEvery { repository.getLearnItems("cursor1", any(), any(), any(), any(), any()) } returns secondPage + coEvery { getLearnMyContentInProgressItemsUseCase(match { it.cursor == null }) } returns firstPage + coEvery { getLearnMyContentInProgressItemsUseCase(match { it.cursor == "cursor1" }) } returns secondPage val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -371,13 +350,13 @@ class LearnMyContentInProgressViewModelTest { @Test fun `loadMore error shows snackbar and clears isMoreLoading`() = runTest { - coEvery { repository.getLearnItems(null, any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(match { it.cursor == null }) } returns LearnItemsResponse( items = listOf(createTestProgramItem()), pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 2, null) ) val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { repository.getLearnItems("cursor1", any(), any(), any(), any(), any()) } throws Exception("Network error") + coEvery { getLearnMyContentInProgressItemsUseCase(match { it.cursor == "cursor1" }) } throws Exception("Network error") viewModel.uiState.value.increaseTotalItemCount() @@ -393,36 +372,24 @@ class LearnMyContentInProgressViewModelTest { advanceTimeBy(350) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = "kotlin", - sortBy = any(), - status = any(), - itemTypes = any(), - forceNetwork = false, - ) + getLearnMyContentInProgressItemsUseCase(match { it.cursor == null && it.searchQuery == "kotlin" }) } } @Test - fun `Empty search query passes null searchQuery to repository`() = runTest { + fun `Empty search query passes null searchQuery to use case`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = any(), - status = any(), - itemTypes = any(), - forceNetwork = false, - ) + getLearnMyContentInProgressItemsUseCase(match { it.cursor == null && it.searchQuery == null }) } } - private fun getViewModel() = LearnMyContentInProgressViewModel(resources, repository) + private fun getViewModel() = LearnMyContentInProgressViewModel( + resources, getLearnMyContentInProgressItemsUseCase, getNextModuleItemUseCase, networkStateProvider, featureFlagProvider + ) private fun createTestProgramItem( id: String = "program1", @@ -462,4 +429,4 @@ class LearnMyContentInProgressViewModelTest { workflowState = "available", lastActivityAt = null, ) -} +} \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt index 4c620cdaca..b9db598f60 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt @@ -24,9 +24,14 @@ import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibrary import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryItemsUseCase +import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryRecommendationsUseCase +import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase +import com.instructure.horizon.domain.usecase.ToggleLearnLearningLibraryItemBookmarkUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter -import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -51,8 +56,12 @@ import java.util.Date class LearnMyContentSavedViewModelTest { private val resources: Resources = mockk(relaxed = true) - private val myContentRepository: LearnMyContentRepository = mockk(relaxed = true) - private val savedContentRepository: LearnMyContentSavedRepository = mockk(relaxed = true) + private val getLearnLearningLibraryItemsUseCase: GetLearnLearningLibraryItemsUseCase = mockk(relaxed = true) + private val getLearnLearningLibraryRecommendationsUseCase: GetLearnLearningLibraryRecommendationsUseCase = mockk(relaxed = true) + private val toggleLearnLearningLibraryItemBookmarkUseCase: ToggleLearnLearningLibraryItemBookmarkUseCase = mockk(relaxed = true) + private val getNextModuleItemUseCase: GetNextModuleItemUseCase = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val emptyResponse = LearningLibraryCollectionItemsResponse( @@ -66,10 +75,10 @@ class LearnMyContentSavedViewModelTest { every { resources.getString(any()) } returns "" every { resources.getString(any(), *anyVararg()) } returns "" every { resources.getQuantityString(any(), any(), *anyVararg()) } returns "" - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns emptyResponse - coEvery { myContentRepository.getFirstPageModulesWithItems(any(), any()) } returns emptyList() - coEvery { savedContentRepository.getLearningLibraryRecommendedItems(any()) } returns emptyList() - coEvery { savedContentRepository.toggleLearningLibraryItemIsBookmarked(any()) } returns false + coEvery { getLearnLearningLibraryItemsUseCase(any()) } returns emptyResponse + coEvery { getLearnLearningLibraryRecommendationsUseCase(any()) } returns emptyList() + coEvery { toggleLearnLearningLibraryItemBookmarkUseCase(any()) } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns false } @After @@ -85,19 +94,19 @@ class LearnMyContentSavedViewModelTest { } @Test - fun `onFiltersChanged calls getBookmarkedLearningLibraryItems`() = runTest { + fun `onFiltersChanged calls use case with bookmarkedOnly true`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) coVerify { - myContentRepository.getBookmarkedLearningLibraryItems( - afterCursor = null, - searchQuery = null, - sortBy = CollectionItemSortOption.MOST_RECENT, - types = null, - forceNetwork = false, - ) + getLearnLearningLibraryItemsUseCase(match { + it.cursor == null && + it.searchQuery == null && + it.sortBy == CollectionItemSortOption.MOST_RECENT && + it.typeFilter == null && + it.bookmarkedOnly == true + }) } } @@ -107,7 +116,7 @@ class LearnMyContentSavedViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coVerify { savedContentRepository.getLearningLibraryRecommendedItems(false) } + coVerify { getLearnLearningLibraryRecommendationsUseCase(match { !it.forceRefresh }) } } @Test @@ -117,13 +126,11 @@ class LearnMyContentSavedViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Courses) coVerify { - myContentRepository.getBookmarkedLearningLibraryItems( - afterCursor = null, - searchQuery = null, - sortBy = any(), - types = listOf(CollectionItemType.COURSE), - forceNetwork = false, - ) + getLearnLearningLibraryItemsUseCase(match { + it.cursor == null && + it.typeFilter == CollectionItemType.COURSE && + it.bookmarkedOnly == true + }) } } @@ -134,19 +141,13 @@ class LearnMyContentSavedViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) coVerify { - myContentRepository.getBookmarkedLearningLibraryItems( - afterCursor = null, - searchQuery = null, - sortBy = any(), - types = null, - forceNetwork = false, - ) + getLearnLearningLibraryItemsUseCase(match { it.typeFilter == null && it.bookmarkedOnly == true }) } } @Test fun `Successful load populates contentCards`() = runTest { - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { getLearnLearningLibraryItemsUseCase(any()) } returns LearningLibraryCollectionItemsResponse( items = listOf(createTestCollectionItem(id = "item1", name = "Saved Course")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -160,7 +161,7 @@ class LearnMyContentSavedViewModelTest { @Test fun `Load error sets isError true`() = runTest { - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } throws Exception("Network error") + coEvery { getLearnLearningLibraryItemsUseCase(any()) } throws Exception("Network error") val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -170,7 +171,7 @@ class LearnMyContentSavedViewModelTest { @Test fun `showMoreButton is true when pageInfo has next page`() = runTest { - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { getLearnLearningLibraryItemsUseCase(any()) } returns LearningLibraryCollectionItemsResponse( items = listOf(createTestCollectionItem()), pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 10, null) ) @@ -182,31 +183,25 @@ class LearnMyContentSavedViewModelTest { } @Test - fun `Refresh fetches recommendations with forceNetwork true`() = runTest { + fun `Refresh re-fetches recommendations`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) viewModel.uiState.value.loadingState.onRefresh() - coVerify { savedContentRepository.getLearningLibraryRecommendedItems(true) } + coVerify { getLearnLearningLibraryRecommendationsUseCase(match { !it.forceRefresh }) } + coVerify { getLearnLearningLibraryRecommendationsUseCase(match { it.forceRefresh }) } } @Test - fun `Refresh calls getBookmarkedLearningLibraryItems with forceNetwork true`() = runTest { + fun `Refresh re-fetches bookmarked items`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) viewModel.uiState.value.loadingState.onRefresh() - coVerify { - myContentRepository.getBookmarkedLearningLibraryItems( - afterCursor = null, - searchQuery = null, - sortBy = any(), - types = null, - forceNetwork = true, - ) - } + coVerify { getLearnLearningLibraryItemsUseCase(match { !it.forceRefresh }) } + coVerify { getLearnLearningLibraryItemsUseCase(match { it.forceRefresh }) } } @Test @@ -219,8 +214,8 @@ class LearnMyContentSavedViewModelTest { items = listOf(createTestCollectionItem(id = "item2")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 2, null) ) - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(null, any(), any(), any(), any(), any()) } returns firstPage - coEvery { myContentRepository.getBookmarkedLearningLibraryItems("cursor1", any(), any(), any(), any(), any()) } returns secondPage + coEvery { getLearnLearningLibraryItemsUseCase(match { it.cursor == null }) } returns firstPage + coEvery { getLearnLearningLibraryItemsUseCase(match { it.cursor == "cursor1" }) } returns secondPage val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -231,7 +226,7 @@ class LearnMyContentSavedViewModelTest { @Test fun `onBookmarkItem sets bookmarkLoading true then removes item on success`() = runTest { - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { getLearnLearningLibraryItemsUseCase(any()) } returns LearningLibraryCollectionItemsResponse( items = listOf(createTestCollectionItem(id = "item1")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -245,7 +240,7 @@ class LearnMyContentSavedViewModelTest { @Test fun `onBookmarkItem calls toggleLearningLibraryItemIsBookmarked`() = runTest { - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { getLearnLearningLibraryItemsUseCase(any()) } returns LearningLibraryCollectionItemsResponse( items = listOf(createTestCollectionItem(id = "item1")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -254,16 +249,16 @@ class LearnMyContentSavedViewModelTest { viewModel.onBookmarkItem("item1") - coVerify { savedContentRepository.toggleLearningLibraryItemIsBookmarked("item1") } + coVerify { toggleLearnLearningLibraryItemBookmarkUseCase(match { it.itemId == "item1" }) } } @Test fun `onBookmarkItem error keeps item in list with bookmarkLoading false`() = runTest { - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { getLearnLearningLibraryItemsUseCase(any()) } returns LearningLibraryCollectionItemsResponse( items = listOf(createTestCollectionItem(id = "item1")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) - coEvery { savedContentRepository.toggleLearningLibraryItemIsBookmarked("item1") } throws Exception("Network error") + coEvery { toggleLearnLearningLibraryItemBookmarkUseCase(match { it.itemId == "item1" }) } throws Exception("Network error") val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -276,12 +271,12 @@ class LearnMyContentSavedViewModelTest { @Test fun `onBookmarkItem error shows snackbar message`() = runTest { - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { getLearnLearningLibraryItemsUseCase(any()) } returns LearningLibraryCollectionItemsResponse( items = listOf(createTestCollectionItem(id = "item1")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) every { resources.getString(R.string.learnMyContentSavedFailedToBookmarkErrorMessage) } returns "Failed to save" - coEvery { savedContentRepository.toggleLearningLibraryItemIsBookmarked("item1") } throws Exception("Network error") + coEvery { toggleLearnLearningLibraryItemBookmarkUseCase(match { it.itemId == "item1" }) } throws Exception("Network error") val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -294,7 +289,7 @@ class LearnMyContentSavedViewModelTest { fun `Refresh error shows snackbar`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), true) } throws Exception("Network error") + coEvery { getLearnLearningLibraryItemsUseCase(any()) } throws Exception("Network error") viewModel.uiState.value.loadingState.onRefresh() @@ -306,7 +301,7 @@ class LearnMyContentSavedViewModelTest { fun `Dismiss snackbar clears snackbar message`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), true) } throws Exception("Error") + coEvery { getLearnLearningLibraryItemsUseCase(any()) } throws Exception("Error") viewModel.uiState.value.loadingState.onRefresh() viewModel.uiState.value.loadingState.onSnackbarDismiss() @@ -314,7 +309,10 @@ class LearnMyContentSavedViewModelTest { assertNull(viewModel.uiState.value.loadingState.snackbarMessage) } - private fun getViewModel() = LearnMyContentSavedViewModel(resources, myContentRepository, savedContentRepository) + private fun getViewModel() = LearnMyContentSavedViewModel( + resources, getLearnLearningLibraryItemsUseCase, getLearnLearningLibraryRecommendationsUseCase, + toggleLearnLearningLibraryItemBookmarkUseCase, getNextModuleItemUseCase, networkStateProvider, featureFlagProvider + ) private fun createTestCollectionItem( id: String = "testItem", diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt index 14df17cf46..8d493122ff 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt @@ -53,7 +53,7 @@ class FeatureFlagProvider( } suspend fun offlineEnabled(): Boolean { - return checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary + return true//checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary } private suspend fun checkEnvironmentFeatureFlag(featureFlag: String): Boolean { From 1b88ec54ade8561d034ea17401eff165da9e6fbc Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 8 Apr 2026 10:53:57 +0200 Subject: [PATCH 12/31] Implement learn details screen offline mode --- .../CourseDetailsLocalDataSource.kt | 111 ++++++ .../CourseDetailsNetworkDataSource.kt} | 25 +- .../CourseProgressLocalDataSource.kt | 139 ++++++++ .../CourseProgressNetworkDataSource.kt | 40 +++ .../datasource/CourseScoreLocalDataSource.kt | 146 ++++++++ .../CourseScoreNetworkDataSource.kt} | 36 +- .../ProgramDetailsLocalDataSource.kt | 97 ++++++ .../ProgramDetailsNetworkDataSource.kt} | 20 +- .../data/repository/CourseRepository.kt | 86 +++++ .../data/repository/CourseScoreRepository.kt | 59 ++++ .../repository/HorizonFileSyncRepository.kt | 118 +++++++ .../data/repository/ProgramRepository.kt | 27 ++ .../horizon/database/HorizonDatabase.kt | 28 +- .../horizon/database/HorizonTypeConverters.kt | 7 + .../database/dao/HorizonCourseModuleDao.kt | 58 ++++ .../database/dao/HorizonCourseScoreDao.kt | 71 ++++ .../database/dao/HorizonFileFolderDao.kt | 32 ++ .../database/dao/HorizonLearnCourseDao.kt | 38 ++ .../database/dao/HorizonLocalFileDao.kt | 35 ++ .../entity/HorizonCourseAssignmentEntity.kt | 48 +++ .../HorizonCourseAssignmentGroupEntity.kt | 34 ++ .../entity/HorizonCourseGradeEntity.kt | 29 ++ .../entity/HorizonCourseModuleEntity.kt | 38 ++ .../entity/HorizonCourseModuleItemEntity.kt | 57 +++ .../entity/HorizonFileFolderEntity.kt | 27 ++ .../entity/HorizonLearnCourseEntity.kt | 34 ++ .../database/entity/HorizonLocalFileEntity.kt | 29 ++ .../entity/HorizonSyncMetadataEntity.kt | 3 + .../horizon/di/HorizonOfflineModule.kt | 30 ++ .../course/details/CourseDetailsViewModel.kt | 21 +- .../progress/CourseProgressRepository.kt | 43 --- .../progress/CourseProgressViewModel.kt | 21 +- .../details/score/CourseScoreViewModel.kt | 20 +- .../details/ProgramDetailsViewModel.kt | 21 +- .../data/repository/CourseRepositoryTest.kt | 328 ++++++++++++++++++ .../repository/CourseScoreRepositoryTest.kt | 155 +++++++++ .../data/repository/ProgramRepositoryTest.kt | 230 ++++++++++++ .../details/CourseDetailsRepositoryTest.kt | 218 ------------ .../details/CourseDetailsViewModelTest.kt | 14 +- .../progress/CourseProgressRepositoryTest.kt | 142 -------- .../score/CourseScoreRepositoryTest.kt | 159 --------- .../details/ProgramDetailsRepositoryTest.kt | 178 ---------- .../details/ProgramDetailsViewModelTest.kt | 11 +- .../features/offline/sync/HtmlParser.kt | 19 +- 44 files changed, 2279 insertions(+), 803 deletions(-) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt rename libs/horizon/src/main/java/com/instructure/horizon/{features/learn/course/details/CourseDetailsRepository.kt => data/datasource/CourseDetailsNetworkDataSource.kt} (70%) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseScoreLocalDataSource.kt rename libs/horizon/src/main/java/com/instructure/horizon/{features/learn/course/details/score/CourseScoreRepository.kt => data/datasource/CourseScoreNetworkDataSource.kt} (56%) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt rename libs/horizon/src/main/java/com/instructure/horizon/{features/learn/program/details/ProgramDetailsRepository.kt => data/datasource/ProgramDetailsNetworkDataSource.kt} (71%) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseScoreRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseScoreDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonFileFolderDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCourseDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLocalFileDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseAssignmentEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseAssignmentGroupEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseGradeEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCourseEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLocalFileEntity.kt delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressRepository.kt create mode 100644 libs/horizon/src/test/java/com/instructure/horizon/data/repository/CourseRepositoryTest.kt create mode 100644 libs/horizon/src/test/java/com/instructure/horizon/data/repository/CourseScoreRepositoryTest.kt create mode 100644 libs/horizon/src/test/java/com/instructure/horizon/data/repository/ProgramRepositoryTest.kt delete mode 100644 libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsRepositoryTest.kt delete mode 100644 libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressRepositoryTest.kt delete mode 100644 libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreRepositoryTest.kt delete mode 100644 libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsRepositoryTest.kt diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt new file mode 100644 index 0000000000..96e347e531 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement +import com.instructure.horizon.database.dao.HorizonDashboardProgramDao +import com.instructure.horizon.database.dao.HorizonLearnCourseDao +import com.instructure.horizon.database.entity.HorizonDashboardProgramCourseRef +import com.instructure.horizon.database.entity.HorizonDashboardProgramEntity +import com.instructure.horizon.database.entity.HorizonLearnCourseEntity +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity +import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.SyncDataType +import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus +import com.instructure.journey.type.ProgramVariantType +import java.util.Date +import javax.inject.Inject + +class CourseDetailsLocalDataSource @Inject constructor( + private val learnCourseDao: HorizonLearnCourseDao, + private val programDao: HorizonDashboardProgramDao, + private val syncMetadataDao: HorizonSyncMetadataDao, +) { + + suspend fun getCourse(courseId: Long): CourseWithProgress { + val entity = learnCourseDao.getByCourseId(courseId) + ?: throw IllegalStateException("Course $courseId not found in cache") + return entity.toCourseWithProgress() + } + + suspend fun getProgramsForCourse(courseId: Long): List { + val allPrograms = programDao.getAll() + return allPrograms.mapNotNull { programEntity -> + val refs = programDao.getRefsForProgram(programEntity.programId) + val program = programEntity.toProgram(refs) + if (program.sortedRequirements.firstOrNull()?.courseId == courseId) program else null + } + } + + suspend fun saveCourseDetails(course: CourseWithProgress, programs: List) { + learnCourseDao.insertAll(listOf(course.toEntity())) + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + dataType = SyncDataType.COURSE_DETAILS, + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } + + private fun HorizonLearnCourseEntity.toCourseWithProgress(): CourseWithProgress { + return CourseWithProgress( + courseId = courseId, + courseName = courseName, + courseImageUrl = null, + courseSyllabus = courseSyllabus, + progress = progress, + ) + } + + private fun CourseWithProgress.toEntity(): HorizonLearnCourseEntity { + return HorizonLearnCourseEntity( + courseId = courseId, + courseName = courseName, + progress = progress, + courseSyllabus = courseSyllabus, + startDateMs = null, + endDateMs = null, + moduleItemsDurations = "", + ) + } + + private fun HorizonDashboardProgramEntity.toProgram(refs: List): Program { + val requirements = refs.sortedBy { it.sortOrder }.map { ref -> + ProgramRequirement( + id = ref.requirementId, + progressId = ref.progressId, + courseId = ref.courseId, + required = ref.required, + progress = ref.progress, + enrollmentStatus = ref.enrollmentStatus?.let { + runCatching { ProgramProgressCourseEnrollmentStatus.valueOf(it) }.getOrNull() + }, + ) + } + return Program( + id = programId, + name = programName, + description = description, + startDate = startDateMs?.let { Date(it) }, + endDate = endDateMs?.let { Date(it) }, + variant = runCatching { ProgramVariantType.valueOf(variant) }.getOrDefault(ProgramVariantType.LINEAR), + courseCompletionCount = courseCompletionCount, + sortedRequirements = requirements, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsNetworkDataSource.kt similarity index 70% rename from libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsRepository.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsNetworkDataSource.kt index 069e80b0d9..2bcef5a5a4 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsNetworkDataSource.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 - present Instructure, Inc. + * Copyright (C) 2026 - present Instructure, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.features.learn.course.details +package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.apis.ExternalToolAPI import com.instructure.canvasapi2.builders.RestParams @@ -25,24 +25,27 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs import javax.inject.Inject -class CourseDetailsRepository @Inject constructor( +class CourseDetailsNetworkDataSource @Inject constructor( private val getCoursesManager: HorizonGetCoursesManager, private val getProgramsManager: GetProgramsManager, private val externalToolApi: ExternalToolAPI.ExternalToolInterface, - private val apiPrefs: ApiPrefs + private val apiPrefs: ApiPrefs, ) { - suspend fun getCourse(courseId: Long, forceNetwork: Boolean): CourseWithProgress { - return getCoursesManager.getCourseWithProgressById(courseId, apiPrefs.user?.id ?: -1L, forceNetwork).dataOrThrow + + suspend fun getCourse(courseId: Long, forceRefresh: Boolean): CourseWithProgress { + return getCoursesManager.getCourseWithProgressById(courseId, apiPrefs.user?.id ?: -1L, forceRefresh).dataOrThrow } - suspend fun getProgramsForCourse(courseId: Long, forceNetwork: Boolean): List { - return getProgramsManager.getPrograms(forceNetwork).filter { it.sortedRequirements.firstOrNull()?.courseId == courseId } + suspend fun getProgramsForCourse(courseId: Long, forceRefresh: Boolean): List { + return getProgramsManager.getPrograms(forceRefresh).filter { + it.sortedRequirements.firstOrNull()?.courseId == courseId + } } - suspend fun hasExternalTools(courseId: Long, forceNetwork: Boolean): Boolean { + suspend fun hasExternalTools(courseId: Long, forceRefresh: Boolean): Boolean { return externalToolApi.getExternalToolsForCourses( listOf(CanvasContext.emptyCourseContext(courseId).contextId), - RestParams(isForceReadFromNetwork = forceNetwork) + RestParams(isForceReadFromNetwork = forceRefresh) ).dataOrNull.orEmpty().isNotEmpty() } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt new file mode 100644 index 0000000000..ac594a316e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.models.ModuleCompletionRequirement +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.ModuleItem +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.database.dao.HorizonCourseModuleDao +import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonCourseModuleEntity +import com.instructure.horizon.database.entity.HorizonCourseModuleItemEntity +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity +import com.instructure.horizon.database.entity.SyncDataType +import javax.inject.Inject + +class CourseProgressLocalDataSource @Inject constructor( + private val courseModuleDao: HorizonCourseModuleDao, + private val syncMetadataDao: HorizonSyncMetadataDao, +) { + + suspend fun getModuleItems(courseId: Long): List { + val moduleEntities = courseModuleDao.getModulesForCourse(courseId) + return moduleEntities.map { moduleEntity -> + val itemEntities = courseModuleDao.getItemsForModule(moduleEntity.moduleId) + moduleEntity.toModuleObject(itemEntities) + } + } + + suspend fun saveModuleItems(courseId: Long, modules: List) { + val moduleEntities = modules.map { it.toModuleEntity(courseId) } + val itemEntities = modules.flatMap { module -> + module.items.map { it.toModuleItemEntity(module.id, courseId) } + } + courseModuleDao.replaceForCourse(courseId, moduleEntities, itemEntities) + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + dataType = SyncDataType.COURSE_MODULES, + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } + + private fun HorizonCourseModuleEntity.toModuleObject(items: List): ModuleObject { + val prerequisiteIds = if (prerequisiteIds.isEmpty()) null + else prerequisiteIds.split(",").mapNotNull { it.toLongOrNull() }.toLongArray() + return ModuleObject( + id = moduleId, + position = position, + name = name, + state = state, + estimatedDuration = estimatedDuration, + prerequisiteIds = prerequisiteIds, + items = items.map { it.toModuleItem() }, + ) + } + + private fun HorizonCourseModuleItemEntity.toModuleItem(): ModuleItem { + val completionRequirement = completionRequirementType?.let { + ModuleCompletionRequirement( + type = it, + minScore = completionRequirementMinScore, + completed = completionRequirementCompleted, + ) + } + val moduleDetails = if (pointsPossible != null || dueAt != null || lockedForUser || lockExplanation != null) { + ModuleContentDetails( + pointsPossible = pointsPossible, + dueAt = dueAt, + lockedForUser = lockedForUser, + lockExplanation = lockExplanation, + lockAt = lockAt, + unlockAt = unlockAt, + ) + } else null + return ModuleItem( + id = itemId, + moduleId = moduleId, + position = position, + title = title, + type = type, + htmlUrl = htmlUrl, + url = url, + completionRequirement = completionRequirement, + moduleDetails = moduleDetails, + quizLti = quizLti, + estimatedDuration = estimatedDuration, + ) + } + + private fun ModuleObject.toModuleEntity(courseId: Long): HorizonCourseModuleEntity { + return HorizonCourseModuleEntity( + moduleId = id, + courseId = courseId, + name = name, + position = position, + state = state, + estimatedDuration = estimatedDuration, + prerequisiteIds = prerequisiteIds?.joinToString(",") ?: "", + ) + } + + private fun ModuleItem.toModuleItemEntity(moduleId: Long, courseId: Long): HorizonCourseModuleItemEntity { + return HorizonCourseModuleItemEntity( + itemId = id, + moduleId = moduleId, + courseId = courseId, + title = title, + position = position, + type = type, + htmlUrl = htmlUrl, + url = url, + completionRequirementType = completionRequirement?.type, + completionRequirementMinScore = completionRequirement?.minScore ?: 0.0, + completionRequirementCompleted = completionRequirement?.completed ?: false, + pointsPossible = moduleDetails?.pointsPossible, + dueAt = moduleDetails?.dueAt, + lockedForUser = moduleDetails?.lockedForUser ?: false, + lockExplanation = moduleDetails?.lockExplanation, + lockAt = moduleDetails?.lockAt, + unlockAt = moduleDetails?.unlockAt, + quizLti = quizLti, + estimatedDuration = estimatedDuration, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt new file mode 100644 index 0000000000..43810a416d --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.apis.ModuleAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.canvasapi2.utils.depaginate +import javax.inject.Inject + +class CourseProgressNetworkDataSource @Inject constructor( + private val moduleApi: ModuleAPI.ModuleInterface, +) { + + suspend fun getModuleItems(courseId: Long, forceRefresh: Boolean): List { + val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceRefresh) + return moduleApi.getFirstPageModulesWithItems( + CanvasContext.Type.COURSE.apiString, + courseId, + params, + includes = listOf("estimated_durations") + ) + .depaginate { moduleApi.getNextPageModuleObjectList(it, params) } + .dataOrThrow + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseScoreLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseScoreLocalDataSource.kt new file mode 100644 index 0000000000..cf24894d50 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseScoreLocalDataSource.kt @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.AssignmentGroup +import com.instructure.canvasapi2.models.Enrollment +import com.instructure.canvasapi2.models.Grades +import com.instructure.canvasapi2.models.Submission +import com.instructure.canvasapi2.models.SubmissionComment +import com.instructure.horizon.database.dao.HorizonCourseScoreDao +import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonCourseAssignmentEntity +import com.instructure.horizon.database.entity.HorizonCourseAssignmentGroupEntity +import com.instructure.horizon.database.entity.HorizonCourseGradeEntity +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity +import com.instructure.horizon.database.entity.SyncDataType +import java.util.Date +import javax.inject.Inject + +class CourseScoreLocalDataSource @Inject constructor( + private val courseScoreDao: HorizonCourseScoreDao, + private val syncMetadataDao: HorizonSyncMetadataDao, +) { + + suspend fun getAssignmentGroups(courseId: Long): List { + val groupEntities = courseScoreDao.getGroupsForCourse(courseId) + return groupEntities.map { groupEntity -> + val assignmentEntities = courseScoreDao.getAssignmentsForGroup(groupEntity.groupId) + groupEntity.toAssignmentGroup(assignmentEntities) + } + } + + suspend fun getEnrollments(courseId: Long): List { + val grade = courseScoreDao.getGradeForCourse(courseId) + return listOf( + Enrollment( + enrollmentState = EnrollmentAPI.STATE_ACTIVE, + grades = grade?.let { Grades(currentScore = it.currentScore) }, + ) + ) + } + + suspend fun saveScoreData( + courseId: Long, + assignmentGroups: List, + enrollments: List, + ) { + val groupEntities = assignmentGroups.map { it.toGroupEntity(courseId) } + val assignmentEntities = assignmentGroups.flatMap { group -> + group.assignments.map { it.toAssignmentEntity(group.id, courseId) } + } + val activeEnrollment = enrollments.firstOrNull { it.enrollmentState == EnrollmentAPI.STATE_ACTIVE } + val gradeEntity = activeEnrollment?.currentScore?.let { + HorizonCourseGradeEntity(courseId = courseId, currentScore = it) + } + courseScoreDao.replaceForCourse(courseId, groupEntities, assignmentEntities, gradeEntity) + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + dataType = SyncDataType.COURSE_SCORES, + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } + + private fun HorizonCourseAssignmentGroupEntity.toAssignmentGroup( + assignmentEntities: List, + ): AssignmentGroup { + return AssignmentGroup( + id = groupId, + name = name, + groupWeight = groupWeight, + assignments = assignmentEntities.map { it.toAssignment() }, + ) + } + + private fun HorizonCourseAssignmentEntity.toAssignment(): Assignment { + val hasSubmissionData = submissionGrade != null || submissionWorkflowState != null + || submissionExcused || submissionMissing || submissionLate + || submissionPostedAtMs != null || submissionCustomGradeStatusId != null + val submission = if (hasSubmissionData) { + Submission( + grade = submissionGrade, + workflowState = submissionWorkflowState, + excused = submissionExcused, + missing = submissionMissing, + late = submissionLate, + postedAt = submissionPostedAtMs?.let { Date(it) }, + customGradeStatusId = submissionCustomGradeStatusId, + submissionComments = List(submissionCommentsCount) { SubmissionComment() }, + ) + } else null + return Assignment( + id = assignmentId, + name = name, + pointsPossible = pointsPossible, + dueAt = dueAt, + submission = submission, + ) + } + + private fun AssignmentGroup.toGroupEntity(courseId: Long): HorizonCourseAssignmentGroupEntity { + return HorizonCourseAssignmentGroupEntity( + groupId = id, + courseId = courseId, + name = name, + groupWeight = groupWeight, + ) + } + + private fun Assignment.toAssignmentEntity(groupId: Long, courseId: Long): HorizonCourseAssignmentEntity { + val lastSubmission = submission?.takeIf { + it.workflowState == "graded" || it.workflowState == "submitted" + } + return HorizonCourseAssignmentEntity( + assignmentId = id, + groupId = groupId, + courseId = courseId, + name = name, + pointsPossible = pointsPossible, + dueAt = dueAt, + submissionGrade = submission?.grade, + submissionWorkflowState = submission?.workflowState, + submissionExcused = submission?.excused ?: false, + submissionMissing = submission?.missing ?: false, + submissionLate = submission?.late ?: false, + submissionPostedAtMs = submission?.postedAt?.time, + submissionCustomGradeStatusId = submission?.customGradeStatusId, + submissionCommentsCount = lastSubmission?.submissionComments?.size ?: 0, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseScoreNetworkDataSource.kt similarity index 56% rename from libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreRepository.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseScoreNetworkDataSource.kt index 25d9d6e074..f9063368d6 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseScoreNetworkDataSource.kt @@ -1,20 +1,19 @@ /* - * Copyright (C) 2025 - present Instructure, Inc. + * Copyright (C) 2026 - present Instructure, Inc. * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . + * http://www.apache.org/licenses/LICENSE-2.0 * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ -package com.instructure.horizon.features.learn.course.details.score +package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.apis.AssignmentAPI import com.instructure.canvasapi2.apis.EnrollmentAPI @@ -25,22 +24,23 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.depaginate import javax.inject.Inject -class CourseScoreRepository @Inject constructor( +class CourseScoreNetworkDataSource @Inject constructor( private val assignmentApi: AssignmentAPI.AssignmentInterface, private val enrollmentApi: EnrollmentAPI.EnrollmentInterface, - private val apiPrefs: ApiPrefs + private val apiPrefs: ApiPrefs, ) { - suspend fun getAssignmentGroups(courseId: Long, forceRefresh: Boolean = false): List { + + suspend fun getAssignmentGroups(courseId: Long, forceRefresh: Boolean): List { val restParams = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceRefresh) return assignmentApi.getFirstPageAssignmentGroupListWithAssignments(courseId, restParams) .depaginate { assignmentApi.getNextPageAssignmentGroupListWithAssignments(it, restParams) } .dataOrThrow } - suspend fun getEnrollments(courseId: Long, forceNetwork: Boolean): List { - val restParams = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceNetwork) + suspend fun getEnrollments(courseId: Long, forceRefresh: Boolean): List { + val restParams = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceRefresh) return enrollmentApi.getEnrollmentsForUserInCourse(courseId, apiPrefs.user?.id ?: -1, restParams) .depaginate { enrollmentApi.getNextPage(it, restParams) } .dataOrThrow } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt new file mode 100644 index 0000000000..b0661ee44c --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement +import com.instructure.horizon.database.dao.HorizonDashboardProgramDao +import com.instructure.horizon.database.dao.HorizonLearnCourseDao +import com.instructure.horizon.database.entity.HorizonDashboardProgramCourseRef +import com.instructure.horizon.database.entity.HorizonDashboardProgramEntity +import com.instructure.horizon.database.entity.HorizonLearnCourseEntity +import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus +import com.instructure.journey.type.ProgramVariantType +import java.util.Date +import javax.inject.Inject + +class ProgramDetailsLocalDataSource @Inject constructor( + private val programDao: HorizonDashboardProgramDao, + private val learnCourseDao: HorizonLearnCourseDao, +) { + + suspend fun getProgramDetails(programId: String): Program { + val entity = programDao.getAll().find { it.programId == programId } + ?: throw IllegalArgumentException("Program with id $programId not found in cache") + val refs = programDao.getRefsForProgram(programId) + return entity.toProgram(refs) + } + + suspend fun getCoursesById(courseIds: List): List { + return learnCourseDao.getByCourseIds(courseIds).map { it.toCourseWithModuleItemDurations() } + } + + suspend fun saveCourses(courses: List) { + learnCourseDao.insertAll(courses.map { it.toEntity() }) + } + + private fun HorizonDashboardProgramEntity.toProgram(refs: List): Program { + val requirements = refs.sortedBy { it.sortOrder }.map { ref -> + ProgramRequirement( + id = ref.requirementId, + progressId = ref.progressId, + courseId = ref.courseId, + required = ref.required, + progress = ref.progress, + enrollmentStatus = ref.enrollmentStatus?.let { + runCatching { ProgramProgressCourseEnrollmentStatus.valueOf(it) }.getOrNull() + }, + ) + } + return Program( + id = programId, + name = programName, + description = description, + startDate = startDateMs?.let { Date(it) }, + endDate = endDateMs?.let { Date(it) }, + variant = runCatching { ProgramVariantType.valueOf(variant) }.getOrDefault(ProgramVariantType.LINEAR), + courseCompletionCount = courseCompletionCount, + sortedRequirements = requirements, + ) + } + + private fun HorizonLearnCourseEntity.toCourseWithModuleItemDurations(): CourseWithModuleItemDurations { + return CourseWithModuleItemDurations( + courseId = courseId, + courseName = courseName, + moduleItemsDuration = if (moduleItemsDurations.isEmpty()) emptyList() else moduleItemsDurations.split(","), + startDate = startDateMs?.let { Date(it) }, + endDate = endDateMs?.let { Date(it) }, + ) + } + + private fun CourseWithModuleItemDurations.toEntity(): HorizonLearnCourseEntity { + return HorizonLearnCourseEntity( + courseId = courseId, + courseName = courseName, + progress = 0.0, + courseSyllabus = null, + startDateMs = startDate?.time, + endDateMs = endDate?.time, + moduleItemsDurations = moduleItemsDuration.joinToString(","), + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsNetworkDataSource.kt similarity index 71% rename from libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsRepository.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsNetworkDataSource.kt index e37ae4f712..06ef61a016 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsNetworkDataSource.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 - present Instructure, Inc. + * Copyright (C) 2026 - present Instructure, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.features.learn.program.details +package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager @@ -25,23 +25,23 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import javax.inject.Inject -class ProgramDetailsRepository @Inject constructor( +class ProgramDetailsNetworkDataSource @Inject constructor( private val getProgramsManager: GetProgramsManager, - private val getCoursesManager: HorizonGetCoursesManager + private val getCoursesManager: HorizonGetCoursesManager, ) { - suspend fun getProgramDetails(programId: String, forceNetwork: Boolean = false): Program { - val program = getProgramsManager.getPrograms(forceNetwork).find { it.id == programId } + + suspend fun getProgramDetails(programId: String, forceRefresh: Boolean): Program { + return getProgramsManager.getPrograms(forceRefresh).find { it.id == programId } ?: throw IllegalArgumentException("Program with id $programId not found") - return program } - suspend fun getCoursesById(courseIds: List, forceNetwork: Boolean = false): List = coroutineScope { + suspend fun getCoursesById(courseIds: List, forceRefresh: Boolean): List = coroutineScope { courseIds.map { id -> - async { getCoursesManager.getProgramCourses(id, forceNetwork).dataOrThrow } + async { getCoursesManager.getProgramCourses(id, forceRefresh).dataOrThrow } }.awaitAll() } suspend fun enrollCourse(progressId: String): DataResult { return getProgramsManager.enrollCourse(progressId) } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt new file mode 100644 index 0000000000..bb675ad33d --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.data.datasource.CourseDetailsLocalDataSource +import com.instructure.horizon.data.datasource.CourseDetailsNetworkDataSource +import com.instructure.horizon.data.datasource.CourseProgressLocalDataSource +import com.instructure.horizon.data.datasource.CourseProgressNetworkDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.features.offline.sync.HtmlParser +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class CourseRepository @Inject constructor( + private val courseDetailsNetworkDataSource: CourseDetailsNetworkDataSource, + private val courseDetailsLocalDataSource: CourseDetailsLocalDataSource, + private val courseProgressNetworkDataSource: CourseProgressNetworkDataSource, + private val courseProgressLocalDataSource: CourseProgressLocalDataSource, + private val htmlParser: HtmlParser, + private val fileSyncRepository: HorizonFileSyncRepository, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getCourse(courseId: Long, forceRefresh: Boolean): CourseWithProgress { + return if (shouldFetchFromNetwork()) { + courseDetailsNetworkDataSource.getCourse(courseId, forceRefresh) + .also { course -> + if (shouldSync()) { + val parsedSyllabus = htmlParser.createHtmlStringWithLocalFiles(course.courseSyllabus, course.courseId) + val programs = courseDetailsNetworkDataSource.getProgramsForCourse(courseId, forceRefresh = false) + courseDetailsLocalDataSource.saveCourseDetails(course.copy(courseSyllabus = parsedSyllabus.htmlWithLocalFileLinks), programs) + fileSyncRepository.syncHtmlFiles(course.courseId, parsedSyllabus) + } + } + } else { + courseDetailsLocalDataSource.getCourse(courseId) + } + } + + suspend fun getProgramsForCourse(courseId: Long, forceRefresh: Boolean): List { + return if (shouldFetchFromNetwork()) { + courseDetailsNetworkDataSource.getProgramsForCourse(courseId, forceRefresh) + } else { + courseDetailsLocalDataSource.getProgramsForCourse(courseId) + } + } + + suspend fun hasExternalTools(courseId: Long, forceRefresh: Boolean): Boolean { + return if (shouldFetchFromNetwork()) { + courseDetailsNetworkDataSource.hasExternalTools(courseId, forceRefresh) + } else { + false + } + } + + suspend fun getModuleItems(courseId: Long, forceRefresh: Boolean): List { + return if (shouldFetchFromNetwork()) { + courseProgressNetworkDataSource.getModuleItems(courseId, forceRefresh) + .also { if (shouldSync()) courseProgressLocalDataSource.saveModuleItems(courseId, it) } + } else { + courseProgressLocalDataSource.getModuleItems(courseId) + } + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseScoreRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseScoreRepository.kt new file mode 100644 index 0000000000..0b2d7bb93a --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseScoreRepository.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.models.AssignmentGroup +import com.instructure.canvasapi2.models.Enrollment +import com.instructure.horizon.data.datasource.CourseScoreLocalDataSource +import com.instructure.horizon.data.datasource.CourseScoreNetworkDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class CourseScoreRepository @Inject constructor( + private val networkDataSource: CourseScoreNetworkDataSource, + private val localDataSource: CourseScoreLocalDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getAssignmentGroups(courseId: Long, forceRefresh: Boolean = false): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getAssignmentGroups(courseId, forceRefresh) + } else { + localDataSource.getAssignmentGroups(courseId) + } + } + + suspend fun getEnrollments(courseId: Long, forceRefresh: Boolean): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getEnrollments(courseId, forceRefresh) + } else { + localDataSource.getEnrollments(courseId) + } + } + + suspend fun saveScoreData(courseId: Long, assignmentGroups: List, enrollments: List) { + if (shouldSync()) { + localDataSource.saveScoreData(courseId, assignmentGroups, enrollments) + } + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt new file mode 100644 index 0000000000..6d980872f4 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt @@ -0,0 +1,118 @@ +package com.instructure.horizon.data.repository + +import android.content.Context +import android.net.Uri +import com.instructure.canvasapi2.apis.DownloadState +import com.instructure.canvasapi2.apis.FileDownloadAPI +import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.canvasapi2.apis.saveFile +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.horizon.database.dao.HorizonFileFolderDao +import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.horizon.database.entity.HorizonFileFolderEntity +import com.instructure.horizon.database.entity.HorizonLocalFileEntity +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.features.offline.sync.HtmlParsingResult +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import java.io.File +import java.util.Date +import javax.inject.Inject + +class HorizonFileSyncRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val fileDownloadApi: FileDownloadAPI, + private val localFileDao: HorizonLocalFileDao, + private val fileFolderDao: HorizonFileFolderDao, + private val fileFolderApi: FileFolderAPI.FilesFoldersInterface, + private val apiPrefs: ApiPrefs, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun syncHtmlFiles(courseId: Long, parsingResult: HtmlParsingResult) { + val alreadyDownloadedIds = localFileDao.findByCourseId(courseId).map { it.id }.toSet() + val internalFileIdsToSync = parsingResult.internalFileIds.filterNot { alreadyDownloadedIds.contains(it) } + + coroutineScope { + internalFileIdsToSync.chunked(6).forEach { chunk -> + chunk.map { fileId -> async { downloadInternalFile(fileId, courseId) } }.awaitAll() + } + } + + coroutineScope { + parsingResult.externalFileUrls.toList().chunked(6).forEach { chunk -> + chunk.map { url -> async { downloadExternalFile(url, courseId) } }.awaitAll() + } + } + } + + private suspend fun downloadInternalFile(fileId: Long, courseId: Long) { + val fileInfo = resolveInternalFileInfo(fileId, courseId) ?: return + val dir = File(context.filesDir, apiPrefs.user?.id.toString()).also { it.mkdirs() } + val destFile = File(dir, "${fileId}_${fileInfo.displayName}") + + if (destFile.exists()) return + + downloadToFile(fileInfo.url, destFile, shouldIgnoreToken = false) { + localFileDao.insert( + HorizonLocalFileEntity( + fileId, + courseId, + Date(), + destFile.absolutePath + ) + ) + } + } + + private suspend fun resolveInternalFileInfo(fileId: Long, courseId: Long): HorizonFileFolderEntity? { + fileFolderDao.findById(fileId)?.let { return it } + + val file = fileFolderApi.getCourseFile( + courseId, fileId, + RestParams(isForceReadFromNetwork = true, shouldLoginOnTokenError = false) + ).dataOrNull ?: return null + + val url = file.url ?: return null + val displayName = file.displayName ?: return null + + val entity = HorizonFileFolderEntity(fileId, url, displayName) + fileFolderDao.insert(entity) + return entity + } + + private suspend fun downloadExternalFile(url: String, courseId: Long) { + val fileName = Uri.parse(url).lastPathSegment ?: return + val dir = File(context.filesDir, "${apiPrefs.user?.id}/external_$courseId").also { it.mkdirs() } + val destFile = File(dir, fileName) + + if (destFile.exists()) return + + downloadToFile(url, destFile, shouldIgnoreToken = true) {} + } + + private suspend fun downloadToFile(url: String, destFile: File, shouldIgnoreToken: Boolean, onSuccess: suspend () -> Unit) { + val body = fileDownloadApi.downloadFile( + url, + RestParams(shouldIgnoreToken = shouldIgnoreToken, shouldLoginOnTokenError = false) + ).dataOrNull ?: return + + body.saveFile(destFile).collect { state -> + when (state) { + is DownloadState.Success -> onSuccess() + is DownloadState.Failure -> destFile.delete() + else -> {} + } + } + } + + override suspend fun sync() { + TODO("Not yet implemented — will sync all/selected course files") + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt index 422a9a970c..dd9e6476c5 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt @@ -15,7 +15,11 @@ */ package com.instructure.horizon.data.repository +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.horizon.data.datasource.ProgramDetailsLocalDataSource +import com.instructure.horizon.data.datasource.ProgramDetailsNetworkDataSource import com.instructure.horizon.data.datasource.ProgramLocalDataSource import com.instructure.horizon.data.datasource.ProgramNetworkDataSource import com.instructure.horizon.offline.OfflineSyncRepository @@ -26,6 +30,8 @@ import javax.inject.Inject class ProgramRepository @Inject constructor( private val networkDataSource: ProgramNetworkDataSource, private val localDataSource: ProgramLocalDataSource, + private val programDetailsNetworkDataSource: ProgramDetailsNetworkDataSource, + private val programDetailsLocalDataSource: ProgramDetailsLocalDataSource, private val enrollmentRepository: CourseEnrollmentRepository, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, @@ -45,6 +51,27 @@ class ProgramRepository @Inject constructor( } } + suspend fun getProgramDetails(programId: String, forceRefresh: Boolean = false): Program { + return if (shouldFetchFromNetwork()) { + programDetailsNetworkDataSource.getProgramDetails(programId, forceRefresh) + } else { + programDetailsLocalDataSource.getProgramDetails(programId) + } + } + + suspend fun getCoursesById(courseIds: List, forceRefresh: Boolean = false): List { + return if (shouldFetchFromNetwork()) { + programDetailsNetworkDataSource.getCoursesById(courseIds, forceRefresh) + .also { if (shouldSync()) programDetailsLocalDataSource.saveCourses(it) } + } else { + programDetailsLocalDataSource.getCoursesById(courseIds) + } + } + + suspend fun enrollCourse(progressId: String): DataResult { + return programDetailsNetworkDataSource.enrollCourse(progressId) + } + override suspend fun sync() { TODO("Not yet implemented") } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt index 17e5efa323..6629b76089 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt @@ -18,21 +18,34 @@ package com.instructure.horizon.database import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import com.instructure.horizon.database.dao.HorizonCourseModuleDao +import com.instructure.horizon.database.dao.HorizonCourseScoreDao import com.instructure.horizon.database.dao.HorizonDashboardEnrollmentDao import com.instructure.horizon.database.dao.HorizonDashboardModuleItemDao import com.instructure.horizon.database.dao.HorizonDashboardProgramDao +import com.instructure.horizon.database.dao.HorizonFileFolderDao import com.instructure.horizon.database.dao.HorizonLearnCollectionDao +import com.instructure.horizon.database.dao.HorizonLearnCourseDao 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.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonCourseAssignmentEntity +import com.instructure.horizon.database.entity.HorizonCourseAssignmentGroupEntity +import com.instructure.horizon.database.entity.HorizonCourseGradeEntity +import com.instructure.horizon.database.entity.HorizonCourseModuleEntity +import com.instructure.horizon.database.entity.HorizonCourseModuleItemEntity import com.instructure.horizon.database.entity.HorizonDashboardEnrollmentEntity import com.instructure.horizon.database.entity.HorizonDashboardModuleItemEntity import com.instructure.horizon.database.entity.HorizonDashboardProgramCourseRef import com.instructure.horizon.database.entity.HorizonDashboardProgramEntity +import com.instructure.horizon.database.entity.HorizonFileFolderEntity import com.instructure.horizon.database.entity.HorizonLearnCollectionEntity import com.instructure.horizon.database.entity.HorizonLearnCollectionItemEntity +import com.instructure.horizon.database.entity.HorizonLearnCourseEntity import com.instructure.horizon.database.entity.HorizonLearnItemEntity import com.instructure.horizon.database.entity.HorizonLearnSavedItemEntity +import com.instructure.horizon.database.entity.HorizonLocalFileEntity import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity @TypeConverters(HorizonTypeConverters::class) @@ -47,8 +60,16 @@ import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity HorizonLearnCollectionEntity::class, HorizonLearnCollectionItemEntity::class, HorizonLearnSavedItemEntity::class, + HorizonLearnCourseEntity::class, + HorizonCourseModuleEntity::class, + HorizonCourseModuleItemEntity::class, + HorizonCourseAssignmentGroupEntity::class, + HorizonCourseAssignmentEntity::class, + HorizonCourseGradeEntity::class, + HorizonLocalFileEntity::class, + HorizonFileFolderEntity::class, ], - version = 3, + version = 5, ) abstract class HorizonDatabase : RoomDatabase() { abstract fun dashboardEnrollmentDao(): HorizonDashboardEnrollmentDao @@ -58,4 +79,9 @@ abstract class HorizonDatabase : RoomDatabase() { abstract fun learnItemDao(): HorizonLearnItemDao abstract fun learnCollectionDao(): HorizonLearnCollectionDao abstract fun learnSavedItemDao(): HorizonLearnSavedItemDao + abstract fun learnCourseDao(): HorizonLearnCourseDao + abstract fun courseModuleDao(): HorizonCourseModuleDao + abstract fun courseScoreDao(): HorizonCourseScoreDao + abstract fun localFileDao(): HorizonLocalFileDao + abstract fun fileFolderDao(): HorizonFileFolderDao } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonTypeConverters.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonTypeConverters.kt index c079e84003..2720cc8eaf 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonTypeConverters.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonTypeConverters.kt @@ -17,6 +17,7 @@ package com.instructure.horizon.database import androidx.room.TypeConverter import com.instructure.horizon.database.entity.SyncDataType +import java.util.Date class HorizonTypeConverters { @@ -25,4 +26,10 @@ class HorizonTypeConverters { @TypeConverter fun toSyncDataType(value: String): SyncDataType = SyncDataType.valueOf(value) + + @TypeConverter + fun fromDate(value: Date?): Long? = value?.time + + @TypeConverter + fun toDate(value: Long?): Date? = value?.let { Date(it) } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt new file mode 100644 index 0000000000..c97bcd9337 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.instructure.horizon.database.entity.HorizonCourseModuleEntity +import com.instructure.horizon.database.entity.HorizonCourseModuleItemEntity + +@Dao +interface HorizonCourseModuleDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertModules(modules: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItems(items: List) + + @Query("SELECT * FROM horizon_course_modules WHERE courseId = :courseId ORDER BY position") + suspend fun getModulesForCourse(courseId: Long): List + + @Query("SELECT * FROM horizon_course_module_items WHERE moduleId = :moduleId ORDER BY position") + suspend fun getItemsForModule(moduleId: Long): List + + @Query("DELETE FROM horizon_course_modules WHERE courseId = :courseId") + suspend fun deleteModulesForCourse(courseId: Long) + + @Query("DELETE FROM horizon_course_module_items WHERE courseId = :courseId") + suspend fun deleteItemsForCourse(courseId: Long) + + @Transaction + suspend fun replaceForCourse( + courseId: Long, + modules: List, + items: List, + ) { + deleteItemsForCourse(courseId) + deleteModulesForCourse(courseId) + insertModules(modules) + insertItems(items) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseScoreDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseScoreDao.kt new file mode 100644 index 0000000000..67555bae05 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseScoreDao.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.instructure.horizon.database.entity.HorizonCourseAssignmentEntity +import com.instructure.horizon.database.entity.HorizonCourseAssignmentGroupEntity +import com.instructure.horizon.database.entity.HorizonCourseGradeEntity + +@Dao +interface HorizonCourseScoreDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertGroups(groups: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAssignments(assignments: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertGrade(grade: HorizonCourseGradeEntity) + + @Query("SELECT * FROM horizon_course_assignment_groups WHERE courseId = :courseId") + suspend fun getGroupsForCourse(courseId: Long): List + + @Query("SELECT * FROM horizon_course_assignments WHERE groupId = :groupId") + suspend fun getAssignmentsForGroup(groupId: Long): List + + @Query("SELECT * FROM horizon_course_grades WHERE courseId = :courseId") + suspend fun getGradeForCourse(courseId: Long): HorizonCourseGradeEntity? + + @Query("DELETE FROM horizon_course_assignment_groups WHERE courseId = :courseId") + suspend fun deleteGroupsForCourse(courseId: Long) + + @Query("DELETE FROM horizon_course_assignments WHERE courseId = :courseId") + suspend fun deleteAssignmentsForCourse(courseId: Long) + + @Query("DELETE FROM horizon_course_grades WHERE courseId = :courseId") + suspend fun deleteGradeForCourse(courseId: Long) + + @Transaction + suspend fun replaceForCourse( + courseId: Long, + groups: List, + assignments: List, + grade: HorizonCourseGradeEntity?, + ) { + deleteAssignmentsForCourse(courseId) + deleteGroupsForCourse(courseId) + deleteGradeForCourse(courseId) + insertGroups(groups) + insertAssignments(assignments) + grade?.let { insertGrade(it) } + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonFileFolderDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonFileFolderDao.kt new file mode 100644 index 0000000000..b55075c8a3 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonFileFolderDao.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.horizon.database.entity.HorizonFileFolderEntity + +@Dao +interface HorizonFileFolderDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(fileFolder: HorizonFileFolderEntity) + + @Query("SELECT * FROM HorizonFileFolderEntity WHERE id = :id") + suspend fun findById(id: Long): HorizonFileFolderEntity? +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCourseDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCourseDao.kt new file mode 100644 index 0000000000..68c5d2519d --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCourseDao.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.horizon.database.entity.HorizonLearnCourseEntity + +@Dao +interface HorizonLearnCourseDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(courses: List) + + @Query("SELECT * FROM horizon_learn_courses WHERE courseId = :courseId") + suspend fun getByCourseId(courseId: Long): HorizonLearnCourseEntity? + + @Query("SELECT * FROM horizon_learn_courses WHERE courseId IN (:courseIds)") + suspend fun getByCourseIds(courseIds: List): List + + @Query("DELETE FROM horizon_learn_courses") + suspend fun deleteAll() +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLocalFileDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLocalFileDao.kt new file mode 100644 index 0000000000..07c8870aea --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLocalFileDao.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.horizon.database.entity.HorizonLocalFileEntity + +@Dao +interface HorizonLocalFileDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(localFile: HorizonLocalFileEntity) + + @Query("SELECT * FROM HorizonLocalFileEntity WHERE id = :id") + suspend fun findById(id: Long): HorizonLocalFileEntity? + + @Query("SELECT * FROM HorizonLocalFileEntity WHERE courseId = :courseId") + suspend fun findByCourseId(courseId: Long): List +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseAssignmentEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseAssignmentEntity.kt new file mode 100644 index 0000000000..e7501f46bc --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseAssignmentEntity.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Flattened entity for [com.instructure.canvasapi2.models.Assignment] with submission data + * needed to reconstruct [com.instructure.horizon.model.AssignmentStatus] offline. + * [courseId] is stored for efficient per-course deletion. + * Submission fields are prefixed with "submission" and are null when no submission exists. + */ +@Entity( + tableName = "horizon_course_assignments", + indices = [Index("groupId"), Index("courseId")] +) +data class HorizonCourseAssignmentEntity( + @PrimaryKey val assignmentId: Long, + val groupId: Long, + val courseId: Long, + val name: String?, + val pointsPossible: Double, + val dueAt: String?, + // Submission fields flattened + val submissionGrade: String?, + val submissionWorkflowState: String?, + val submissionExcused: Boolean, + val submissionMissing: Boolean, + val submissionLate: Boolean, + val submissionPostedAtMs: Long?, + val submissionCustomGradeStatusId: Long?, + val submissionCommentsCount: Int, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseAssignmentGroupEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseAssignmentGroupEntity.kt new file mode 100644 index 0000000000..f1a3b211ec --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseAssignmentGroupEntity.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Stores [com.instructure.canvasapi2.models.AssignmentGroup] data per course for offline use. + */ +@Entity( + tableName = "horizon_course_assignment_groups", + indices = [Index("courseId")] +) +data class HorizonCourseAssignmentGroupEntity( + @PrimaryKey val groupId: Long, + val courseId: Long, + val name: String?, + val groupWeight: Double, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseGradeEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseGradeEntity.kt new file mode 100644 index 0000000000..8d917fed79 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseGradeEntity.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Stores the current grade score per course, derived from the active + * [com.instructure.canvasapi2.models.Enrollment] for the Course Scores screen. + */ +@Entity(tableName = "horizon_course_grades") +data class HorizonCourseGradeEntity( + @PrimaryKey val courseId: Long, + val currentScore: Double, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleEntity.kt new file mode 100644 index 0000000000..917baab90d --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleEntity.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Stores [com.instructure.canvasapi2.models.ModuleObject] header data per course for offline use. + * [prerequisiteIds] is stored as a comma-separated list of Long IDs, or an empty string if none. + */ +@Entity( + tableName = "horizon_course_modules", + indices = [Index("courseId")] +) +data class HorizonCourseModuleEntity( + @PrimaryKey val moduleId: Long, + val courseId: Long, + val name: String?, + val position: Int, + val state: String?, + val estimatedDuration: String?, + val prerequisiteIds: String, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt new file mode 100644 index 0000000000..941694d6b1 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Flattened entity for [com.instructure.canvasapi2.models.ModuleItem] belonging to a course module. + * [courseId] is stored for efficient per-course deletion. + * [completionRequirementType], [completionRequirementMinScore], [completionRequirementCompleted] + * are from [com.instructure.canvasapi2.models.ModuleCompletionRequirement]. + * [pointsPossible], [dueAt], [lockedForUser], [lockExplanation], [lockAt], [unlockAt] + * are from [com.instructure.canvasapi2.models.ModuleContentDetails]. + */ +@Entity( + tableName = "horizon_course_module_items", + indices = [Index("moduleId"), Index("courseId")] +) +data class HorizonCourseModuleItemEntity( + @PrimaryKey val itemId: Long, + val moduleId: Long, + val courseId: Long, + val title: String?, + val position: Int, + val type: String?, + val htmlUrl: String?, + val url: String?, + // ModuleCompletionRequirement flattened + val completionRequirementType: String?, + val completionRequirementMinScore: Double, + val completionRequirementCompleted: Boolean, + // ModuleContentDetails flattened + val pointsPossible: String?, + val dueAt: String?, + val lockedForUser: Boolean, + val lockExplanation: String?, + val lockAt: String?, + val unlockAt: String?, + // Other + val quizLti: Boolean, + val estimatedDuration: String?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt new file mode 100644 index 0000000000..ffd69f3ce0 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class HorizonFileFolderEntity( + @PrimaryKey + val id: Long, + val url: String, + val displayName: String, +) \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCourseEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCourseEntity.kt new file mode 100644 index 0000000000..a5066d9568 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCourseEntity.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.PrimaryKey + +/** + * Stores course data needed by the Program Details and Course Details screens offline. + * [moduleItemsDurations] is stored as a comma-separated list of ISO 8601 duration strings. + */ +@Entity(tableName = "horizon_learn_courses") +data class HorizonLearnCourseEntity( + @PrimaryKey val courseId: Long, + val courseName: String, + val progress: Double, + val courseSyllabus: String?, + val startDateMs: Long?, + val endDateMs: Long?, + val moduleItemsDurations: String, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLocalFileEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLocalFileEntity.kt new file mode 100644 index 0000000000..5cf881df94 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLocalFileEntity.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.util.Date + +@Entity +data class HorizonLocalFileEntity( + @PrimaryKey + val id: Long, + val courseId: Long, + val createdDate: Date, + val path: String, +) \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt index c77b761936..d821b3b816 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt @@ -25,6 +25,9 @@ enum class SyncDataType { LEARN_MY_CONTENT_ITEMS, LEARN_SAVED_ITEMS, LEARN_LIBRARY_COLLECTIONS, + COURSE_DETAILS, + COURSE_MODULES, + COURSE_SCORES, } @Entity(tableName = "horizon_sync_metadata") diff --git a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt index 30755cd1e4..b59caf2a31 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt @@ -18,12 +18,17 @@ 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.HorizonCourseModuleDao +import com.instructure.horizon.database.dao.HorizonCourseScoreDao import com.instructure.horizon.database.dao.HorizonDashboardEnrollmentDao import com.instructure.horizon.database.dao.HorizonDashboardModuleItemDao import com.instructure.horizon.database.dao.HorizonDashboardProgramDao +import com.instructure.horizon.database.dao.HorizonFileFolderDao import com.instructure.horizon.database.dao.HorizonLearnCollectionDao +import com.instructure.horizon.database.dao.HorizonLearnCourseDao 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.HorizonSyncMetadataDao import dagger.Module import dagger.Provides @@ -77,4 +82,29 @@ class HorizonOfflineModule { fun provideHorizonLearnSavedItemDao(db: HorizonDatabase): HorizonLearnSavedItemDao { return db.learnSavedItemDao() } + + @Provides + fun provideHorizonLearnCourseDao(db: HorizonDatabase): HorizonLearnCourseDao { + return db.learnCourseDao() + } + + @Provides + fun provideHorizonCourseModuleDao(db: HorizonDatabase): HorizonCourseModuleDao { + return db.courseModuleDao() + } + + @Provides + fun provideHorizonCourseScoreDao(db: HorizonDatabase): HorizonCourseScoreDao { + return db.courseScoreDao() + } + + @Provides + fun provideHorizonLocalFileDao(db: HorizonDatabase): HorizonLocalFileDao { + return db.localFileDao() + } + + @Provides + fun provideHorizonFileFolderDao(db: HorizonDatabase): HorizonFileFolderDao { + return db.fileFolderDao() + } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModel.kt index eecc3dc261..e23502cf28 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModel.kt @@ -16,11 +16,14 @@ package com.instructure.horizon.features.learn.course.details import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.data.repository.CourseRepository import com.instructure.horizon.features.learn.navigation.LearnRoute +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -30,8 +33,10 @@ import javax.inject.Inject @HiltViewModel class CourseDetailsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val repository: CourseDetailsRepository -) : ViewModel() { + private val repository: CourseRepository, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { private val _uiState = MutableStateFlow(CourseDetailsUiState()) val state = _uiState.asStateFlow() @@ -52,6 +57,14 @@ class CourseDetailsViewModel @Inject constructor( } } + override fun onNetworkRestored() { + loadData() + } + + override fun onNetworkLost() { + // Offline banner is handled at the screen level; no action needed here + } + suspend fun fetchData(forceRefresh: Boolean = false) { val course = repository.getCourse(courseId, forceRefresh) val programs = repository.getProgramsForCourse(courseId, forceRefresh) @@ -72,4 +85,4 @@ class CourseDetailsViewModel @Inject constructor( ) } } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressRepository.kt deleted file mode 100644 index 5a746d75a2..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressRepository.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.course.details.progress - -import com.instructure.canvasapi2.apis.ModuleAPI -import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.ModuleObject -import com.instructure.canvasapi2.utils.depaginate -import javax.inject.Inject - -class CourseProgressRepository @Inject constructor( - private val moduleApi: ModuleAPI.ModuleInterface -) { - suspend fun getModuleItems( - courseId: Long, - forceRefresh: Boolean, - ): List { - val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceRefresh) - return moduleApi.getFirstPageModulesWithItems( - CanvasContext.Type.COURSE.apiString, - courseId, - params, - includes = listOf("estimated_durations") - ) - .depaginate { moduleApi.getNextPageModuleObjectList(it, params) } - .dataOrThrow - } -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressViewModel.kt index d48c45b535..ba2a88f0d0 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressViewModel.kt @@ -17,7 +17,6 @@ package com.instructure.horizon.features.learn.course.details.progress import android.content.Context -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.utils.weave.catch @@ -25,9 +24,13 @@ import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R import com.instructure.horizon.features.learn.LearnEvent import com.instructure.horizon.features.learn.LearnEventHandler +import com.instructure.horizon.data.repository.CourseRepository import com.instructure.horizon.horizonui.organisms.cards.ModuleHeaderStateMapper import com.instructure.horizon.horizonui.organisms.cards.ModuleItemCardStateMapper import com.instructure.horizon.horizonui.platform.LoadingState +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.orDefault import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -40,11 +43,13 @@ import javax.inject.Inject @HiltViewModel class CourseProgressViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val repository: CourseProgressRepository, + private val repository: CourseRepository, private val moduleHeaderStateMapper: ModuleHeaderStateMapper, private val moduleItemCardStateMapper: ModuleItemCardStateMapper, - private val learnEventHandler: LearnEventHandler -): ViewModel() { + private val learnEventHandler: LearnEventHandler, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { private val _uiState = MutableStateFlow( CourseProgressUiState( screenState = LoadingState( @@ -120,6 +125,14 @@ class CourseProgressViewModel @Inject constructor( _uiState.update { it.copy(moduleItemStates = moduleItemStates) } } + override fun onNetworkRestored() { + if (uiState.value.courseId != -1L) refresh() + } + + override fun onNetworkLost() { + // Offline banner is handled at the screen level; no action needed here + } + fun refresh() { viewModelScope.tryLaunch { _uiState.update { it.copy(screenState = it.screenState.copy(isRefreshing = true)) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreViewModel.kt index 6fc9d18696..c5388cb9ae 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreViewModel.kt @@ -17,14 +17,17 @@ package com.instructure.horizon.features.learn.course.details.score import android.content.Context -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.apis.EnrollmentAPI import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.data.repository.CourseScoreRepository import com.instructure.horizon.horizonui.platform.LoadingState +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.stringValueWithoutTrailingZeros import dagger.hilt.android.lifecycle.HiltViewModel @@ -38,7 +41,9 @@ import javax.inject.Inject class CourseScoreViewModel @Inject constructor( @ApplicationContext private val context: Context, private val courseScoreRepository: CourseScoreRepository, -): ViewModel() { + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { private val _uiState = MutableStateFlow( CourseScoreUiState( @@ -75,10 +80,19 @@ class CourseScoreViewModel @Inject constructor( } } + override fun onNetworkRestored() { + if (uiState.value.courseId != -1L) refresh() + } + + override fun onNetworkLost() { + // Offline banner is handled at the screen level; no action needed here + } + private suspend fun getData(courseId: Long, forceRefresh: Boolean = false) { val assignmentGroups = courseScoreRepository.getAssignmentGroups(courseId, forceRefresh) val assignmentGroupItems = assignmentGroups.map { AssignmentGroupScoreItem(it) } val enrollments = courseScoreRepository.getEnrollments(courseId, forceRefresh) + courseScoreRepository.saveScoreData(courseId, assignmentGroups, enrollments) val grades = enrollments.first { it.enrollmentState == EnrollmentAPI.STATE_ACTIVE }.grades assignments = assignmentGroups.flatMap { it.assignments } val sortedAssignments = sortAssignments() @@ -121,4 +135,4 @@ class CourseScoreViewModel @Inject constructor( it.copy(screenState = it.screenState.copy(snackbarMessage = null)) } } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModel.kt index a552624f78..a7aafa375b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModel.kt @@ -18,7 +18,6 @@ package com.instructure.horizon.features.learn.program.details import android.content.Context import android.content.res.Resources import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program @@ -44,6 +43,10 @@ import com.instructure.pandautils.utils.formatMonthDayYear import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.sum import com.instructure.pandautils.utils.toFormattedString +import com.instructure.horizon.data.repository.ProgramRepository +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow @@ -57,10 +60,12 @@ import kotlin.time.Duration class ProgramDetailsViewModel @Inject constructor( @ApplicationContext private val context: Context, private val resources: Resources, - private val repository: ProgramDetailsRepository, + private val repository: ProgramRepository, private val dashboardEventHandler: DashboardEventHandler, - savedStateHandle: SavedStateHandle -) : ViewModel() { + savedStateHandle: SavedStateHandle, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { private val programId = savedStateHandle.get(LearnRoute.LearnProgramDetailsScreen.programIdAttr) ?: "" @@ -305,6 +310,14 @@ class ProgramDetailsViewModel @Inject constructor( } } + override fun onNetworkRestored() { + loadData() + } + + override fun onNetworkLost() { + // Offline banner is handled at the screen level; no action needed here + } + private fun refreshProgram() { viewModelScope.tryLaunch { _uiState.update { diff --git a/libs/horizon/src/test/java/com/instructure/horizon/data/repository/CourseRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/data/repository/CourseRepositoryTest.kt new file mode 100644 index 0000000000..c571d1dec0 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/data/repository/CourseRepositoryTest.kt @@ -0,0 +1,328 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement +import com.instructure.canvasapi2.models.ModuleItem +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.data.datasource.CourseDetailsLocalDataSource +import com.instructure.horizon.data.datasource.CourseDetailsNetworkDataSource +import com.instructure.horizon.data.datasource.CourseProgressLocalDataSource +import com.instructure.horizon.data.datasource.CourseProgressNetworkDataSource +import com.instructure.horizon.data.repository.HorizonFileSyncRepository +import com.instructure.pandautils.features.offline.sync.HtmlParser +import com.instructure.pandautils.features.offline.sync.HtmlParsingResult +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class CourseRepositoryTest { + private val courseDetailsNetworkDataSource: CourseDetailsNetworkDataSource = mockk(relaxed = true) + private val courseDetailsLocalDataSource: CourseDetailsLocalDataSource = mockk(relaxed = true) + private val courseProgressNetworkDataSource: CourseProgressNetworkDataSource = mockk(relaxed = true) + private val courseProgressLocalDataSource: CourseProgressLocalDataSource = mockk(relaxed = true) + private val htmlParser: HtmlParser = mockk(relaxed = true) + private val fileSyncRepository: HorizonFileSyncRepository = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + + private val testCourse = CourseWithProgress( + courseId = 1L, + courseName = "Test Course", + courseImageUrl = "https://example.com/course.png", + progress = 50.0, + courseSyllabus = "This is the course syllabus" + ) + private val testPrograms = listOf( + Program( + id = "prog1", + name = "Program 1", + description = "Program 1 description", + startDate = null, + endDate = null, + variant = com.instructure.journey.type.ProgramVariantType.LINEAR, + courseCompletionCount = 0, + sortedRequirements = listOf(ProgramRequirement(id = "req1", progressId = "prog1", courseId = 1L, required = true)) + ), + Program( + id = "prog2", + name = "Program 2", + description = "Program 2 description", + startDate = null, + endDate = null, + variant = com.instructure.journey.type.ProgramVariantType.LINEAR, + courseCompletionCount = 0, + sortedRequirements = listOf(ProgramRequirement(id = "req2", progressId = "prog2", courseId = 2L, required = true)) + ) + ) + private val testModules = listOf( + ModuleObject( + id = 1L, + name = "Module 1", + position = 1, + items = listOf( + ModuleItem(id = 101L, title = "Assignment 1", type = "Assignment"), + ModuleItem(id = 102L, title = "Quiz 1", type = "Quiz") + ) + ), + ModuleObject( + id = 2L, + name = "Module 2", + position = 2, + items = listOf( + ModuleItem(id = 201L, title = "Page 1", type = "Page") + ) + ) + ) + + @Before + fun setup() { + every { networkStateProvider.isOnline() } returns true + coEvery { featureFlagProvider.offlineEnabled() } returns false + coEvery { htmlParser.createHtmlStringWithLocalFiles(any(), any()) } returns HtmlParsingResult( + htmlWithLocalFileLinks = "parsed html", + internalFileIds = emptySet(), + externalFileUrls = emptySet(), + studioMediaIds = emptySet(), + ) + coEvery { courseDetailsNetworkDataSource.getCourse(any(), any()) } returns testCourse + coEvery { courseDetailsNetworkDataSource.getProgramsForCourse(any(), any()) } returns testPrograms.filter { prog -> + prog.sortedRequirements.any { it.courseId == 1L } + } + coEvery { courseDetailsNetworkDataSource.hasExternalTools(any(), any()) } returns false + coEvery { courseProgressNetworkDataSource.getModuleItems(any(), any()) } returns testModules + } + + @Test + fun `getCourse returns course from network data source when online`() = runTest { + val repository = getRepository() + val result = repository.getCourse(1L, false) + + assertEquals(testCourse, result) + coVerify { courseDetailsNetworkDataSource.getCourse(1L, false) } + } + + @Test + fun `getCourse with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.getCourse(1L, true) + + coVerify { courseDetailsNetworkDataSource.getCourse(1L, true) } + } + + @Test + fun `getCourse parses syllabus HTML and saves parsed version when syncing`() = runTest { + every { networkStateProvider.isOnline() } returns true + coEvery { featureFlagProvider.offlineEnabled() } returns true + val parsedHtml = "

parsed

" + coEvery { htmlParser.createHtmlStringWithLocalFiles(any(), any()) } returns HtmlParsingResult( + htmlWithLocalFileLinks = parsedHtml, + internalFileIds = emptySet(), + externalFileUrls = emptySet(), + studioMediaIds = emptySet(), + ) + + val repository = getRepository() + repository.getCourse(1L, false) + + coVerify { htmlParser.createHtmlStringWithLocalFiles(testCourse.courseSyllabus, testCourse.courseId) } + coVerify { courseDetailsLocalDataSource.saveCourseDetails(testCourse.copy(courseSyllabus = parsedHtml), any()) } + coVerify { fileSyncRepository.syncHtmlFiles(testCourse.courseId, any()) } + } + + @Test + fun `getCourse returns course from local data source when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { courseDetailsLocalDataSource.getCourse(any()) } returns testCourse + + val repository = getRepository() + val result = repository.getCourse(1L, false) + + assertEquals(testCourse, result) + coVerify { courseDetailsLocalDataSource.getCourse(1L) } + } + + @Test + fun `getProgramsForCourse returns programs from network data source when online`() = runTest { + val repository = getRepository() + val result = repository.getProgramsForCourse(1L, false) + + assertEquals(1, result.size) + assertEquals("Program 1", result[0].name) + coVerify { courseDetailsNetworkDataSource.getProgramsForCourse(1L, false) } + } + + @Test + fun `getProgramsForCourse with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.getProgramsForCourse(1L, true) + + coVerify { courseDetailsNetworkDataSource.getProgramsForCourse(1L, true) } + } + + @Test + fun `getProgramsForCourse returns local data when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { courseDetailsLocalDataSource.getProgramsForCourse(any()) } returns testPrograms + + val repository = getRepository() + val result = repository.getProgramsForCourse(1L, false) + + coVerify { courseDetailsLocalDataSource.getProgramsForCourse(1L) } + assertEquals(2, result.size) + } + + @Test + fun `hasExternalTools returns true when network data source returns true`() = runTest { + coEvery { courseDetailsNetworkDataSource.hasExternalTools(any(), any()) } returns true + val repository = getRepository() + + val result = repository.hasExternalTools(1L, false) + + assertTrue(result) + coVerify { courseDetailsNetworkDataSource.hasExternalTools(1L, false) } + } + + @Test + fun `hasExternalTools returns false when network data source returns false`() = runTest { + coEvery { courseDetailsNetworkDataSource.hasExternalTools(any(), any()) } returns false + val repository = getRepository() + + val result = repository.hasExternalTools(1L, false) + + assertFalse(result) + } + + @Test + fun `hasExternalTools returns false when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + + val repository = getRepository() + val result = repository.hasExternalTools(1L, false) + + assertFalse(result) + } + + @Test + fun `hasExternalTools with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.hasExternalTools(1L, true) + + coVerify { courseDetailsNetworkDataSource.hasExternalTools(1L, true) } + } + + @Test + fun `getModuleItems returns list of modules with items from network when online`() = runTest { + val repository = getRepository() + val result = repository.getModuleItems(1L, false) + + assertEquals(2, result.size) + assertEquals("Module 1", result[0].name) + assertEquals(2, result[0].items.size) + assertEquals("Module 2", result[1].name) + assertEquals(1, result[1].items.size) + coVerify { courseProgressNetworkDataSource.getModuleItems(1L, false) } + } + + @Test + fun `getModuleItems with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.getModuleItems(1L, true) + + coVerify { courseProgressNetworkDataSource.getModuleItems(1L, true) } + } + + @Test + fun `getModuleItems returns local data when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { courseProgressLocalDataSource.getModuleItems(any()) } returns testModules + + val repository = getRepository() + val result = repository.getModuleItems(1L, false) + + assertEquals(2, result.size) + coVerify { courseProgressLocalDataSource.getModuleItems(1L) } + } + + @Test + fun `getModuleItems returns empty list when no modules`() = runTest { + coEvery { courseProgressNetworkDataSource.getModuleItems(any(), any()) } returns emptyList() + val repository = getRepository() + val result = repository.getModuleItems(1L, false) + + assertEquals(0, result.size) + } + + @Test + fun `Multiple programs can contain the same course`() = runTest { + val multiplePrograms = listOf( + Program( + id = "prog1", + name = "Program 1", + description = "Program 1 description", + startDate = null, + endDate = null, + variant = com.instructure.journey.type.ProgramVariantType.LINEAR, + courseCompletionCount = 0, + sortedRequirements = listOf(ProgramRequirement(id = "req1", progressId = "prog1", courseId = 1L, required = true)) + ), + Program( + id = "prog2", + name = "Program 2", + description = "Program 2 description", + startDate = null, + endDate = null, + variant = com.instructure.journey.type.ProgramVariantType.LINEAR, + courseCompletionCount = 0, + sortedRequirements = listOf(ProgramRequirement(id = "req2", progressId = "prog2", courseId = 1L, required = true)) + ) + ) + coEvery { courseDetailsNetworkDataSource.getProgramsForCourse(any(), any()) } returns multiplePrograms + + val repository = getRepository() + val result = repository.getProgramsForCourse(1L, false) + + assertEquals(2, result.size) + } + + private fun getRepository(): CourseRepository { + return CourseRepository( + courseDetailsNetworkDataSource, + courseDetailsLocalDataSource, + courseProgressNetworkDataSource, + courseProgressLocalDataSource, + htmlParser, + fileSyncRepository, + networkStateProvider, + featureFlagProvider + ) + } +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/data/repository/CourseScoreRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/data/repository/CourseScoreRepositoryTest.kt new file mode 100644 index 0000000000..f231a53881 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/data/repository/CourseScoreRepositoryTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.AssignmentGroup +import com.instructure.canvasapi2.models.Enrollment +import com.instructure.canvasapi2.models.Grades +import com.instructure.horizon.data.datasource.CourseScoreLocalDataSource +import com.instructure.horizon.data.datasource.CourseScoreNetworkDataSource +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class CourseScoreRepositoryTest { + private val networkDataSource: CourseScoreNetworkDataSource = mockk(relaxed = true) + private val localDataSource: CourseScoreLocalDataSource = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + + private val testAssignmentGroups = listOf( + AssignmentGroup( + id = 1L, + name = "Homework", + groupWeight = 40.0, + assignments = listOf( + Assignment(id = 101L, name = "Assignment 1"), + Assignment(id = 102L, name = "Assignment 2") + ) + ), + AssignmentGroup( + id = 2L, + name = "Exams", + groupWeight = 60.0, + assignments = listOf( + Assignment(id = 201L, name = "Midterm Exam") + ) + ) + ) + private val testEnrollments = listOf( + Enrollment( + id = 1L, + enrollmentState = EnrollmentAPI.STATE_ACTIVE, + grades = Grades(currentScore = 85.5) + ) + ) + + @Before + fun setup() { + every { networkStateProvider.isOnline() } returns true + coEvery { featureFlagProvider.offlineEnabled() } returns false + coEvery { networkDataSource.getAssignmentGroups(any(), any()) } returns testAssignmentGroups + coEvery { networkDataSource.getEnrollments(any(), any()) } returns testEnrollments + } + + @Test + fun `getAssignmentGroups returns list from network data source when online`() = runTest { + val repository = getRepository() + val result = repository.getAssignmentGroups(1L, false) + + assertEquals(2, result.size) + assertEquals("Homework", result[0].name) + assertEquals(2, result[0].assignments.size) + assertEquals("Exams", result[1].name) + assertEquals(1, result[1].assignments.size) + coVerify { networkDataSource.getAssignmentGroups(1L, false) } + } + + @Test + fun `getAssignmentGroups with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.getAssignmentGroups(1L, true) + + coVerify { networkDataSource.getAssignmentGroups(1L, true) } + } + + @Test + fun `getAssignmentGroups returns local data when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { localDataSource.getAssignmentGroups(any()) } returns testAssignmentGroups + + val repository = getRepository() + val result = repository.getAssignmentGroups(1L, false) + + assertEquals(2, result.size) + coVerify { localDataSource.getAssignmentGroups(1L) } + } + + @Test + fun `getEnrollments returns list from network data source when online`() = runTest { + val repository = getRepository() + val result = repository.getEnrollments(1L, false) + + assertEquals(1, result.size) + assertEquals(EnrollmentAPI.STATE_ACTIVE, result[0].enrollmentState) + assertEquals(85.5, result[0].grades?.currentScore) + coVerify { networkDataSource.getEnrollments(1L, false) } + } + + @Test + fun `getEnrollments with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.getEnrollments(1L, true) + + coVerify { networkDataSource.getEnrollments(1L, true) } + } + + @Test + fun `getEnrollments returns local data when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { localDataSource.getEnrollments(any()) } returns testEnrollments + + val repository = getRepository() + val result = repository.getEnrollments(1L, false) + + assertEquals(1, result.size) + coVerify { localDataSource.getEnrollments(1L) } + } + + @Test + fun `getAssignmentGroups returns empty list when no groups`() = runTest { + coEvery { networkDataSource.getAssignmentGroups(any(), any()) } returns emptyList() + val repository = getRepository() + val result = repository.getAssignmentGroups(1L, false) + + assertEquals(0, result.size) + } + + private fun getRepository(): CourseScoreRepository { + return CourseScoreRepository(networkDataSource, localDataSource, networkStateProvider, featureFlagProvider) + } +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/data/repository/ProgramRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/data/repository/ProgramRepositoryTest.kt new file mode 100644 index 0000000000..81fbcb9d8e --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/data/repository/ProgramRepositoryTest.kt @@ -0,0 +1,230 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.horizon.data.datasource.ProgramDetailsLocalDataSource +import com.instructure.horizon.data.datasource.ProgramDetailsNetworkDataSource +import com.instructure.horizon.data.datasource.ProgramLocalDataSource +import com.instructure.horizon.data.datasource.ProgramNetworkDataSource +import com.instructure.journey.type.ProgramVariantType +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class ProgramRepositoryTest { + private val networkDataSource: ProgramNetworkDataSource = mockk(relaxed = true) + private val localDataSource: ProgramLocalDataSource = mockk(relaxed = true) + private val programDetailsNetworkDataSource: ProgramDetailsNetworkDataSource = mockk(relaxed = true) + private val programDetailsLocalDataSource: ProgramDetailsLocalDataSource = mockk(relaxed = true) + private val enrollmentRepository: CourseEnrollmentRepository = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + + private val testPrograms = listOf( + createTestProgram(id = "program1", name = "Software Engineering", + requirements = listOf(createTestProgramRequirement(courseId = 1L))), + createTestProgram(id = "program2", name = "Data Science", + requirements = listOf(createTestProgramRequirement(courseId = 2L, progress = 50.0))) + ) + + private val testCourses = listOf( + createTestCourse(courseId = 1L, courseName = "Intro to Programming"), + createTestCourse(courseId = 2L, courseName = "Data Analysis"), + createTestCourse(courseId = 3L, courseName = "Machine Learning") + ) + + @Before + fun setup() { + every { networkStateProvider.isOnline() } returns true + coEvery { featureFlagProvider.offlineEnabled() } returns false + coEvery { networkDataSource.getPrograms(any()) } returns testPrograms + coEvery { programDetailsNetworkDataSource.getProgramDetails(any(), any()) } returns testPrograms[0] + coEvery { programDetailsNetworkDataSource.getCoursesById(any(), any()) } returns testCourses + } + + @Test + fun `getPrograms returns programs from network data source when online`() = runTest { + val repository = getRepository() + val result = repository.getPrograms(false) + + assertEquals(2, result.size) + assertEquals("Software Engineering", result[0].name) + coVerify { networkDataSource.getPrograms(false) } + } + + @Test + fun `getPrograms returns local data when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { localDataSource.getPrograms() } returns testPrograms + + val repository = getRepository() + val result = repository.getPrograms(false) + + assertEquals(2, result.size) + coVerify { localDataSource.getPrograms() } + } + + @Test + fun `getProgramDetails returns program from network data source when online`() = runTest { + val repository = getRepository() + val result = repository.getProgramDetails("program1", false) + + assertEquals("program1", result.id) + assertEquals("Software Engineering", result.name) + coVerify { programDetailsNetworkDataSource.getProgramDetails("program1", false) } + } + + @Test + fun `getProgramDetails with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.getProgramDetails("program1", true) + + coVerify { programDetailsNetworkDataSource.getProgramDetails("program1", true) } + } + + @Test + fun `getProgramDetails returns local data when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { programDetailsLocalDataSource.getProgramDetails(any()) } returns testPrograms[0] + + val repository = getRepository() + val result = repository.getProgramDetails("program1", false) + + assertEquals("program1", result.id) + coVerify { programDetailsLocalDataSource.getProgramDetails("program1") } + } + + @Test + fun `getCoursesById returns courses from network data source when online`() = runTest { + coEvery { programDetailsNetworkDataSource.getCoursesById(listOf(1L, 2L, 3L), false) } returns testCourses + + val repository = getRepository() + val result = repository.getCoursesById(listOf(1L, 2L, 3L), false) + + assertEquals(3, result.size) + assertEquals("Intro to Programming", result[0].courseName) + assertEquals("Data Analysis", result[1].courseName) + assertEquals("Machine Learning", result[2].courseName) + coVerify { programDetailsNetworkDataSource.getCoursesById(listOf(1L, 2L, 3L), false) } + } + + @Test + fun `getCoursesById with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.getCoursesById(listOf(1L, 2L), true) + + coVerify { programDetailsNetworkDataSource.getCoursesById(listOf(1L, 2L), true) } + } + + @Test + fun `getCoursesById returns local data when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { programDetailsLocalDataSource.getCoursesById(any()) } returns testCourses + + val repository = getRepository() + val result = repository.getCoursesById(listOf(1L, 2L, 3L), false) + + assertEquals(3, result.size) + coVerify { programDetailsLocalDataSource.getCoursesById(listOf(1L, 2L, 3L)) } + } + + @Test + fun `enrollCourse returns success result`() = runTest { + coEvery { programDetailsNetworkDataSource.enrollCourse(any()) } returns DataResult.Success(Unit) + + val repository = getRepository() + val result = repository.enrollCourse("progress123") + + assertTrue(result.isSuccess) + coVerify { programDetailsNetworkDataSource.enrollCourse("progress123") } + } + + @Test + fun `enrollCourse returns failure result`() = runTest { + coEvery { programDetailsNetworkDataSource.enrollCourse(any()) } returns DataResult.Fail() + + val repository = getRepository() + val result = repository.enrollCourse("progress123") + + assertTrue(result.isFail) + coVerify { programDetailsNetworkDataSource.enrollCourse("progress123") } + } + + private fun getRepository(): ProgramRepository { + return ProgramRepository( + networkDataSource, + localDataSource, + programDetailsNetworkDataSource, + programDetailsLocalDataSource, + enrollmentRepository, + networkStateProvider, + featureFlagProvider + ) + } + + private fun createTestProgram( + id: String = "testProgram", + name: String = "Test Program", + requirements: List = emptyList() + ): Program = Program( + id = id, + name = name, + description = "Test description", + startDate = null, + endDate = null, + variant = ProgramVariantType.LINEAR, + courseCompletionCount = null, + sortedRequirements = requirements + ) + + private fun createTestProgramRequirement( + courseId: Long = 1L, + progress: Double = 0.0 + ): ProgramRequirement = ProgramRequirement( + id = "requirement$courseId", + progressId = "progress$courseId", + courseId = courseId, + required = true, + progress = progress, + enrollmentStatus = null + ) + + private fun createTestCourse( + courseId: Long = 1L, + courseName: String = "Test Course" + ): CourseWithModuleItemDurations = CourseWithModuleItemDurations( + courseId = courseId, + courseName = courseName, + moduleItemsDuration = listOf("PT1H"), + startDate = null, + endDate = null + ) +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsRepositoryTest.kt deleted file mode 100644 index 697312a432..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsRepositoryTest.kt +++ /dev/null @@ -1,218 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.course.details - -import com.instructure.canvasapi2.apis.ExternalToolAPI -import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager -import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetProgramsManager -import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program -import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement -import com.instructure.canvasapi2.models.LTITool -import com.instructure.canvasapi2.models.User -import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.canvasapi2.utils.DataResult -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertFalse -import junit.framework.TestCase.assertTrue -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -class CourseDetailsRepositoryTest { - private val getCoursesManager: HorizonGetCoursesManager = mockk(relaxed = true) - private val getProgramsManager: GetProgramsManager = mockk(relaxed = true) - private val externalToolApi: ExternalToolAPI.ExternalToolInterface = mockk(relaxed = true) - private val apiPrefs: ApiPrefs = mockk(relaxed = true) - - private val testUser = User(id = 789L) - private val testCourse = CourseWithProgress( - courseId = 1L, - courseName = "Test Course", - courseImageUrl = "https://example.com/course.png", - progress = 50.0, - courseSyllabus = "This is the course syllabus" - ) - private val testPrograms = listOf( - Program( - id = "prog1", - name = "Program 1", - description = "Program 1 description", - startDate = null, - endDate = null, - variant = com.instructure.journey.type.ProgramVariantType.LINEAR, - courseCompletionCount = 0, - sortedRequirements = listOf(ProgramRequirement(id = "req1", progressId = "prog1", courseId = 1L, required = true)) - ), - Program( - id = "prog2", - name = "Program 2", - description = "Program 2 description", - startDate = null, - endDate = null, - variant = com.instructure.journey.type.ProgramVariantType.LINEAR, - courseCompletionCount = 0, - sortedRequirements = listOf(ProgramRequirement(id = "req2", progressId = "prog2", courseId = 2L, required = true)) - ) - ) - - @Before - fun setup() { - every { apiPrefs.user } returns testUser - coEvery { getCoursesManager.getCourseWithProgressById(any(), any(), any()) } returns DataResult.Success(testCourse) - coEvery { getProgramsManager.getPrograms(any()) } returns testPrograms - coEvery { externalToolApi.getExternalToolsForCourses(any(), any()) } returns DataResult.Success(emptyList()) - } - - @Test - fun `getCourse returns course with progress`() = runTest { - val repository = getRepository() - val result = repository.getCourse(1L, false) - - assertEquals(testCourse, result) - coVerify { getCoursesManager.getCourseWithProgressById(1L, 789L, false) } - } - - @Test - fun `getCourse with forceNetwork true calls API with force network`() = runTest { - val repository = getRepository() - repository.getCourse(1L, true) - - coVerify { getCoursesManager.getCourseWithProgressById(1L, 789L, true) } - } - - @Test - fun `getCourse uses -1 when user is null`() = runTest { - every { apiPrefs.user } returns null - val repository = getRepository() - repository.getCourse(1L, false) - - coVerify { getCoursesManager.getCourseWithProgressById(1L, -1L, false) } - } - - @Test - fun `getProgramsForCourse returns programs containing the course`() = runTest { - val repository = getRepository() - val result = repository.getProgramsForCourse(1L, false) - - assertEquals(1, result.size) - assertEquals("Program 1", result[0].name) - coVerify { getProgramsManager.getPrograms(false) } - } - - @Test - fun `getProgramsForCourse returns empty list when no programs contain the course`() = runTest { - val repository = getRepository() - val result = repository.getProgramsForCourse(999L, false) - - assertEquals(0, result.size) - } - - @Test - fun `getProgramsForCourse with forceNetwork true calls API with force network`() = runTest { - val repository = getRepository() - repository.getProgramsForCourse(1L, true) - - coVerify { getProgramsManager.getPrograms(true) } - } - - @Test - fun `hasExternalTools returns true when course has tools`() = runTest { - val tools = listOf(LTITool(url = "https://tool.example.com")) - coEvery { externalToolApi.getExternalToolsForCourses(any(), any()) } returns DataResult.Success(tools) - val repository = getRepository() - - val result = repository.hasExternalTools(1L, false) - - assertTrue(result) - coVerify { externalToolApi.getExternalToolsForCourses(listOf("course_1"), any()) } - } - - @Test - fun `hasExternalTools returns false when course has no tools`() = runTest { - coEvery { externalToolApi.getExternalToolsForCourses(any(), any()) } returns DataResult.Success(emptyList()) - val repository = getRepository() - - val result = repository.hasExternalTools(1L, false) - - assertFalse(result) - } - - @Test - fun `hasExternalTools returns false when API returns null`() = runTest { - coEvery { externalToolApi.getExternalToolsForCourses(any(), any()) } returns DataResult.Fail() - val repository = getRepository() - - val result = repository.hasExternalTools(1L, false) - - assertFalse(result) - } - - @Test - fun `hasExternalTools with forceNetwork true calls API with force network`() = runTest { - val repository = getRepository() - repository.hasExternalTools(1L, true) - - coVerify { - externalToolApi.getExternalToolsForCourses( - any(), - match { it.isForceReadFromNetwork } - ) - } - } - - @Test - fun `Multiple programs can contain the same course`() = runTest { - val multiplePrograms = listOf( - Program( - id = "prog1", - name = "Program 1", - description = "Program 1 description", - startDate = null, - endDate = null, - variant = com.instructure.journey.type.ProgramVariantType.LINEAR, - courseCompletionCount = 0, - sortedRequirements = listOf(ProgramRequirement(id = "req1", progressId = "prog1", courseId = 1L, required = true)) - ), - Program( - id = "prog2", - name = "Program 2", - description = "Program 2 description", - startDate = null, - endDate = null, - variant = com.instructure.journey.type.ProgramVariantType.LINEAR, - courseCompletionCount = 0, - sortedRequirements = listOf(ProgramRequirement(id = "req2", progressId = "prog2", courseId = 1L, required = true)) - ) - ) - coEvery { getProgramsManager.getPrograms(any()) } returns multiplePrograms - val repository = getRepository() - - val result = repository.getProgramsForCourse(1L, false) - - assertEquals(2, result.size) - } - - private fun getRepository(): CourseDetailsRepository { - return CourseDetailsRepository(getCoursesManager, getProgramsManager, externalToolApi, apiPrefs) - } -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModelTest.kt index e8556fb877..1acb233efa 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModelTest.kt @@ -20,9 +20,13 @@ import androidx.lifecycle.SavedStateHandle import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement +import com.instructure.horizon.data.repository.CourseRepository import com.instructure.horizon.features.learn.navigation.LearnRoute +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse @@ -39,7 +43,9 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class CourseDetailsViewModelTest { - private val repository: CourseDetailsRepository = mockk(relaxed = true) + private val repository: CourseRepository = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val testCourseId = 123L @@ -66,6 +72,8 @@ class CourseDetailsViewModelTest { @Before fun setup() { Dispatchers.setMain(testDispatcher) + every { networkStateProvider.isOnline() } returns true + coEvery { featureFlagProvider.offlineEnabled() } returns false coEvery { repository.getCourse(any(), any()) } returns testCourse coEvery { repository.getProgramsForCourse(any(), any()) } returns testPrograms coEvery { repository.hasExternalTools(any(), any()) } returns false @@ -211,7 +219,7 @@ class CourseDetailsViewModelTest { @Test fun `Invalid course ID defaults to -1`() { val savedStateHandle = SavedStateHandle() - val viewModel = CourseDetailsViewModel(savedStateHandle, repository) + val viewModel = CourseDetailsViewModel(savedStateHandle, repository, networkStateProvider, featureFlagProvider) coVerify { repository.getCourse(-1L, any()) } } @@ -240,6 +248,6 @@ class CourseDetailsViewModelTest { val savedStateHandle = SavedStateHandle(mapOf( LearnRoute.LearnCourseDetailsScreen.courseIdAttr to courseId )) - return CourseDetailsViewModel(savedStateHandle, repository) + return CourseDetailsViewModel(savedStateHandle, repository, networkStateProvider, featureFlagProvider) } } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressRepositoryTest.kt deleted file mode 100644 index 16d813dbe6..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressRepositoryTest.kt +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.course.details.progress - -import com.instructure.canvasapi2.apis.ModuleAPI -import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.ModuleItem -import com.instructure.canvasapi2.models.ModuleObject -import com.instructure.canvasapi2.utils.DataResult -import com.instructure.canvasapi2.utils.LinkHeaders -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -class CourseProgressRepositoryTest { - private val moduleApi: ModuleAPI.ModuleInterface = mockk(relaxed = true) - - private val testModules = listOf( - ModuleObject( - id = 1L, - name = "Module 1", - position = 1, - items = listOf( - ModuleItem( - id = 101L, - title = "Assignment 1", - type = "Assignment" - ), - ModuleItem( - id = 102L, - title = "Quiz 1", - type = "Quiz" - ) - ) - ), - ModuleObject( - id = 2L, - name = "Module 2", - position = 2, - items = listOf( - ModuleItem( - id = 201L, - title = "Page 1", - type = "Page" - ) - ) - ) - ) - - @Before - fun setup() { - coEvery { moduleApi.getFirstPageModulesWithItems(any(), any(), any(), any()) } returns DataResult.Success( - testModules, - linkHeaders = LinkHeaders() - ) - } - - @Test - fun `getModuleItems returns list of modules with items`() = runTest { - val repository = getRepository() - val result = repository.getModuleItems(1L, false) - - assertEquals(2, result.size) - assertEquals("Module 1", result[0].name) - assertEquals(2, result[0].items.size) - assertEquals("Module 2", result[1].name) - assertEquals(1, result[1].items.size) - coVerify { - moduleApi.getFirstPageModulesWithItems( - CanvasContext.Type.COURSE.apiString, - 1L, - any(), - listOf("estimated_durations") - ) - } - } - - @Test - fun `getModuleItems with forceRefresh true calls API with force network`() = runTest { - val repository = getRepository() - repository.getModuleItems(1L, true) - - coVerify { - moduleApi.getFirstPageModulesWithItems( - any(), - any(), - match { it.isForceReadFromNetwork }, - any() - ) - } - } - - @Test - fun `getModuleItems with forceRefresh false calls API without force network`() = runTest { - val repository = getRepository() - repository.getModuleItems(1L, false) - - coVerify { - moduleApi.getFirstPageModulesWithItems( - any(), - any(), - match { !it.isForceReadFromNetwork }, - any() - ) - } - } - - @Test - fun `getModuleItems returns empty list when no modules`() = runTest { - coEvery { moduleApi.getFirstPageModulesWithItems(any(), any(), any(), any()) } returns DataResult.Success( - emptyList(), - linkHeaders = LinkHeaders() - ) - val repository = getRepository() - val result = repository.getModuleItems(1L, false) - - assertEquals(0, result.size) - } - - private fun getRepository(): CourseProgressRepository { - return CourseProgressRepository(moduleApi) - } -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreRepositoryTest.kt deleted file mode 100644 index e5f3af1274..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreRepositoryTest.kt +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.course.details.score - -import com.instructure.canvasapi2.apis.AssignmentAPI -import com.instructure.canvasapi2.apis.EnrollmentAPI -import com.instructure.canvasapi2.models.Assignment -import com.instructure.canvasapi2.models.AssignmentGroup -import com.instructure.canvasapi2.models.Enrollment -import com.instructure.canvasapi2.models.Grades -import com.instructure.canvasapi2.models.User -import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.canvasapi2.utils.DataResult -import com.instructure.canvasapi2.utils.LinkHeaders -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -class CourseScoreRepositoryTest { - private val assignmentApi: AssignmentAPI.AssignmentInterface = mockk(relaxed = true) - private val enrollmentApi: EnrollmentAPI.EnrollmentInterface = mockk(relaxed = true) - private val apiPrefs: ApiPrefs = mockk(relaxed = true) - - private val testUser = User(id = 999L) - private val testAssignmentGroups = listOf( - AssignmentGroup( - id = 1L, - name = "Homework", - groupWeight = 40.0, - assignments = listOf( - Assignment(id = 101L, name = "Assignment 1"), - Assignment(id = 102L, name = "Assignment 2") - ) - ), - AssignmentGroup( - id = 2L, - name = "Exams", - groupWeight = 60.0, - assignments = listOf( - Assignment(id = 201L, name = "Midterm Exam") - ) - ) - ) - private val testEnrollments = listOf( - Enrollment( - id = 1L, - enrollmentState = EnrollmentAPI.STATE_ACTIVE, - grades = Grades(currentScore = 85.5) - ) - ) - - @Before - fun setup() { - every { apiPrefs.user } returns testUser - coEvery { assignmentApi.getFirstPageAssignmentGroupListWithAssignments(any(), any()) } returns DataResult.Success( - testAssignmentGroups, - linkHeaders = LinkHeaders() - ) - coEvery { enrollmentApi.getEnrollmentsForUserInCourse(any(), any(), any()) } returns DataResult.Success( - testEnrollments, - linkHeaders = LinkHeaders() - ) - } - - @Test - fun `getAssignmentGroups returns list of assignment groups with assignments`() = runTest { - val repository = getRepository() - val result = repository.getAssignmentGroups(1L, false) - - assertEquals(2, result.size) - assertEquals("Homework", result[0].name) - assertEquals(2, result[0].assignments.size) - assertEquals("Exams", result[1].name) - assertEquals(1, result[1].assignments.size) - coVerify { assignmentApi.getFirstPageAssignmentGroupListWithAssignments(1L, any()) } - } - - @Test - fun `getAssignmentGroups with forceRefresh true calls API with force network`() = runTest { - val repository = getRepository() - repository.getAssignmentGroups(1L, true) - - coVerify { - assignmentApi.getFirstPageAssignmentGroupListWithAssignments( - any(), - match { it.isForceReadFromNetwork } - ) - } - } - - @Test - fun `getEnrollments returns list of enrollments`() = runTest { - val repository = getRepository() - val result = repository.getEnrollments(1L, false) - - assertEquals(1, result.size) - assertEquals(EnrollmentAPI.STATE_ACTIVE, result[0].enrollmentState) - assertEquals(85.5, result[0].grades?.currentScore) - coVerify { enrollmentApi.getEnrollmentsForUserInCourse(1L, 999L, any()) } - } - - @Test - fun `getEnrollments with forceNetwork true calls API with force network`() = runTest { - val repository = getRepository() - repository.getEnrollments(1L, true) - - coVerify { - enrollmentApi.getEnrollmentsForUserInCourse( - any(), - any(), - match { it.isForceReadFromNetwork } - ) - } - } - - @Test - fun `getEnrollments uses -1 when user is null`() = runTest { - every { apiPrefs.user } returns null - val repository = getRepository() - repository.getEnrollments(1L, false) - - coVerify { enrollmentApi.getEnrollmentsForUserInCourse(1L, -1L, any()) } - } - - @Test - fun `getAssignmentGroups returns empty list when no groups`() = runTest { - coEvery { assignmentApi.getFirstPageAssignmentGroupListWithAssignments(any(), any()) } returns DataResult.Success( - emptyList(), - linkHeaders = LinkHeaders() - ) - val repository = getRepository() - val result = repository.getAssignmentGroups(1L, false) - - assertEquals(0, result.size) - } - - private fun getRepository(): CourseScoreRepository { - return CourseScoreRepository(assignmentApi, enrollmentApi, apiPrefs) - } -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsRepositoryTest.kt deleted file mode 100644 index 93076ef747..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsRepositoryTest.kt +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.instructure.horizon.features.learn.program.details - -import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager -import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetProgramsManager -import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program -import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement -import com.instructure.canvasapi2.utils.DataResult -import com.instructure.journey.type.ProgramVariantType -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertTrue -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -class ProgramDetailsRepositoryTest { - private val getProgramsManager: GetProgramsManager = mockk(relaxed = true) - private val getCoursesManager: HorizonGetCoursesManager = mockk(relaxed = true) - - private val testPrograms = listOf( - createTestProgram( - id = "program1", - name = "Software Engineering", - requirements = listOf( - createTestProgramRequirement(courseId = 1L, progress = 0.0) - ) - ), - createTestProgram( - id = "program2", - name = "Data Science", - requirements = listOf( - createTestProgramRequirement(courseId = 2L, progress = 50.0) - ) - ) - ) - - private val testCourses = listOf( - createTestCourse(courseId = 1L, courseName = "Intro to Programming"), - createTestCourse(courseId = 2L, courseName = "Data Analysis"), - createTestCourse(courseId = 3L, courseName = "Machine Learning") - ) - - @Before - fun setup() { - coEvery { getProgramsManager.getPrograms(any()) } returns testPrograms - coEvery { getCoursesManager.getProgramCourses(any(), any()) } returns DataResult.Success(testCourses[0]) - } - - @Test - fun `getProgramDetails returns program for valid ID`() = runTest { - val repository = getRepository() - val result = repository.getProgramDetails("program1", false) - - assertEquals("program1", result.id) - assertEquals("Software Engineering", result.name) - coVerify { getProgramsManager.getPrograms(false) } - } - - @Test - fun `getProgramDetails throws exception for invalid ID`() = runTest { - val repository = getRepository() - - try { - repository.getProgramDetails("invalidId", false) - throw AssertionError("Expected IllegalArgumentException to be thrown") - } catch (e: IllegalArgumentException) { - assertTrue(e.message?.contains("Program with id invalidId not found") == true) - } - } - - @Test - fun `getProgramDetails with forceNetwork true calls API with force network`() = runTest { - val repository = getRepository() - repository.getProgramDetails("program1", true) - - coVerify { getProgramsManager.getPrograms(true) } - } - - @Test - fun `getCoursesById fetches all courses in parallel`() = runTest { - coEvery { getCoursesManager.getProgramCourses(1L, false) } returns DataResult.Success(testCourses[0]) - coEvery { getCoursesManager.getProgramCourses(2L, false) } returns DataResult.Success(testCourses[1]) - coEvery { getCoursesManager.getProgramCourses(3L, false) } returns DataResult.Success(testCourses[2]) - - val repository = getRepository() - val result = repository.getCoursesById(listOf(1L, 2L, 3L), false) - - assertEquals(3, result.size) - assertEquals("Intro to Programming", result[0].courseName) - assertEquals("Data Analysis", result[1].courseName) - assertEquals("Machine Learning", result[2].courseName) - coVerify { getCoursesManager.getProgramCourses(1L, false) } - coVerify { getCoursesManager.getProgramCourses(2L, false) } - coVerify { getCoursesManager.getProgramCourses(3L, false) } - } - - @Test - fun `enrollCourse returns success result`() = runTest { - coEvery { getProgramsManager.enrollCourse(any()) } returns DataResult.Success(Unit) - - val repository = getRepository() - val result = repository.enrollCourse("progress123") - - assertTrue(result.isSuccess) - coVerify { getProgramsManager.enrollCourse("progress123") } - } - - @Test - fun `enrollCourse returns failure result`() = runTest { - coEvery { getProgramsManager.enrollCourse(any()) } returns DataResult.Fail() - - val repository = getRepository() - val result = repository.enrollCourse("progress123") - - assertTrue(result.isFail) - coVerify { getProgramsManager.enrollCourse("progress123") } - } - - private fun getRepository(): ProgramDetailsRepository { - return ProgramDetailsRepository(getProgramsManager, getCoursesManager) - } - - private fun createTestProgram( - id: String = "testProgram", - name: String = "Test Program", - requirements: List = emptyList() - ): Program = Program( - id = id, - name = name, - description = "Test description", - startDate = null, - endDate = null, - variant = ProgramVariantType.LINEAR, - courseCompletionCount = null, - sortedRequirements = requirements - ) - - private fun createTestProgramRequirement( - courseId: Long = 1L, - progress: Double = 0.0 - ): ProgramRequirement = ProgramRequirement( - id = "requirement$courseId", - progressId = "progress$courseId", - courseId = courseId, - required = true, - progress = progress, - enrollmentStatus = null - ) - - private fun createTestCourse( - courseId: Long = 1L, - courseName: String = "Test Course" - ): CourseWithModuleItemDurations = CourseWithModuleItemDurations( - courseId = courseId, - courseName = courseName, - moduleItemsDuration = listOf("PT1H"), - startDate = null, - endDate = null - ) -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModelTest.kt index 9c70fd1f4c..fc9864ea95 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModelTest.kt @@ -25,11 +25,14 @@ import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequir import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.canvasapi2.utils.DataResult import com.instructure.horizon.R +import com.instructure.horizon.data.repository.ProgramRepository import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.horizon.features.learn.navigation.LearnRoute import com.instructure.horizon.features.learn.program.details.components.CourseCardStatus import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus import com.instructure.journey.type.ProgramVariantType +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -53,9 +56,11 @@ import java.util.Locale class ProgramDetailsViewModelTest { private val context: Context = mockk(relaxed = true) private val resources: Resources = mockk(relaxed = true) - private val repository: ProgramDetailsRepository = mockk(relaxed = true) + private val repository: ProgramRepository = mockk(relaxed = true) private val dashboardEventHandler: DashboardEventHandler = mockk(relaxed = true) private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val testProgramId = "program123" @@ -68,6 +73,8 @@ class ProgramDetailsViewModelTest { every { context.getSharedPreferences(any(), any()) } returns sharedPrefs every { sharedPrefs.getInt(any(), any()) } returns 0 + every { networkStateProvider.isOnline() } returns true + coEvery { featureFlagProvider.offlineEnabled() } returns false ContextKeeper.appContext = context every { savedStateHandle.get(LearnRoute.LearnProgramDetailsScreen.programIdAttr) } returns testProgramId every { context.getString(any()) } returns "" @@ -649,7 +656,7 @@ class ProgramDetailsViewModelTest { } private fun getViewModel(): ProgramDetailsViewModel { - val viewModel = ProgramDetailsViewModel(context, resources, repository, dashboardEventHandler, savedStateHandle) + val viewModel = ProgramDetailsViewModel(context, resources, repository, dashboardEventHandler, savedStateHandle, networkStateProvider, featureFlagProvider) testDispatcher.scheduler.advanceUntilIdle() return viewModel } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt index 21ee877f56..7cd8d9a0ea 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt @@ -17,7 +17,7 @@ package com.instructure.pandautils.features.offline.sync import android.content.Context -import android.net.Uri +import androidx.core.net.toUri import com.instructure.canvasapi2.apis.FileFolderAPI import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.StudioMediaMetadata @@ -78,8 +78,21 @@ class HtmlParser( val (newHtml, shouldSyncFile) = replaceInternalFileUrl(resultHtml, courseId, fileId, imageUrl) resultHtml = newHtml if (shouldSyncFile) internalFileIds.add(fileId) - } else { - val fileUri = Uri.parse(imageUrl) + } else if (imageUrl.toUri().isRelative) { + val relativeInternalFileRegex = Regex(".*files/(\\d+)") + val fileId = relativeInternalFileRegex.find(imageUrl)?.groupValues?.get(1)?.toLongOrNull() + if (fileId != null) { + val (newHtml, shouldSyncFile) = replaceInternalFileUrl( + resultHtml, + courseId, + fileId, + imageUrl + ) + resultHtml = newHtml + if (shouldSyncFile) internalFileIds.add(fileId) + } + } else { + val fileUri = imageUrl.toUri() val fileName = fileUri.lastPathSegment if (fileName != null && fileUri.scheme == "https") { // We don't allow cleartext traffic in the app. resultHtml = resultHtml.replace(imageUrl, "file://${createLocalFilePathForExternalFile(fileName, courseId)}") From 8a63d093c13b5450e00a95adeea37addc51d11bf Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 8 Apr 2026 11:17:40 +0200 Subject: [PATCH 13/31] Fix network handling --- .../horizon/data/repository/HorizonFileSyncRepository.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt index 6d980872f4..5a702c1306 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt @@ -17,9 +17,11 @@ import com.instructure.pandautils.features.offline.sync.HtmlParsingResult import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext import java.io.File import java.util.Date import javax.inject.Inject @@ -35,7 +37,7 @@ class HorizonFileSyncRepository @Inject constructor( featureFlagProvider: FeatureFlagProvider, ) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { - suspend fun syncHtmlFiles(courseId: Long, parsingResult: HtmlParsingResult) { + suspend fun syncHtmlFiles(courseId: Long, parsingResult: HtmlParsingResult) = withContext(Dispatchers.IO) { val alreadyDownloadedIds = localFileDao.findByCourseId(courseId).map { it.id }.toSet() val internalFileIdsToSync = parsingResult.internalFileIds.filterNot { alreadyDownloadedIds.contains(it) } From f35e6072beacb4a79299e14a63e9203030fbe876 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 8 Apr 2026 12:02:52 +0200 Subject: [PATCH 14/31] Remove mocked feature flag --- .../com/instructure/pandautils/utils/FeatureFlagProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt index 8d493122ff..14df17cf46 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt @@ -53,7 +53,7 @@ class FeatureFlagProvider( } suspend fun offlineEnabled(): Boolean { - return true//checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary + return checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary } private suspend fun checkEnvironmentFeatureFlag(featureFlag: String): Boolean { From 8a189cef0360d927a5ebb60074bb63ae191a7749 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 8 Apr 2026 13:48:34 +0200 Subject: [PATCH 15/31] Improve offline handling --- .../LearnMyContentLocalDataSource.kt | 61 ++++++++++++++++--- .../repository/LearnMyContentRepository.kt | 25 +++++++- .../horizon/database/HorizonDatabase.kt | 2 +- .../entity/HorizonSyncMetadataEntity.kt | 3 +- 4 files changed, 78 insertions(+), 13 deletions(-) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt index 0662f1dbb7..82f4dc63cd 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt @@ -15,6 +15,7 @@ */ package com.instructure.horizon.data.datasource +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo import com.instructure.canvasapi2.models.journey.mycontent.CourseEnrollmentItem import com.instructure.canvasapi2.models.journey.mycontent.LearnItem @@ -34,14 +35,47 @@ class LearnMyContentLocalDataSource @Inject constructor( private val syncMetadataDao: HorizonSyncMetadataDao, ) { - suspend fun getLearnItems(queryKey: String): LearnItemsResponse { - val items = learnItemDao.getByQueryKey(queryKey).map { it.toModel() } + suspend fun getLearnItems( + queryKey: String, + searchQuery: String?, + sortBy: CollectionItemSortOption?, + itemTypes: List?, + cursor: String?, + ): LearnItemsResponse { + var items = learnItemDao.getByQueryKey(queryKey).map { it.toModel() } + + if (!itemTypes.isNullOrEmpty()) { + items = items.filter { item -> + itemTypes.any { type -> + when (type) { + LearnItemType.PROGRAM -> item is ProgramEnrollmentItem + LearnItemType.COURSE -> item is CourseEnrollmentItem + } + } + } + } + + if (!searchQuery.isNullOrBlank()) { + items = items.filter { it.name.contains(searchQuery, ignoreCase = true) } + } + + items = when (sortBy) { + CollectionItemSortOption.NAME_A_Z -> items.sortedBy { it.name } + CollectionItemSortOption.NAME_Z_A -> items.sortedByDescending { it.name } + CollectionItemSortOption.MOST_RECENT -> items.sortedByDescending { it.enrolledAt?.time ?: 0L } + CollectionItemSortOption.LEAST_RECENT -> items.sortedBy { it.enrolledAt?.time ?: Long.MAX_VALUE } + null -> items.sortedBy { it.position } + } + + val offset = cursor?.toIntOrNull() ?: 0 + val page = items.drop(offset).take(PAGE_SIZE) + val hasNextPage = offset + PAGE_SIZE < items.size return LearnItemsResponse( - items = items, + items = page, pageInfo = LearningLibraryPageInfo( - nextCursor = null, + nextCursor = if (hasNextPage) (offset + PAGE_SIZE).toString() else null, previousCursor = null, - hasNextPage = false, + hasNextPage = hasNextPage, hasPreviousPage = false, totalCount = items.size, pageCursors = null, @@ -50,16 +84,27 @@ class LearnMyContentLocalDataSource @Inject constructor( } suspend fun saveLearnItems(items: List, queryKey: String) { - val entities = items.map { it.toEntity(queryKey) } - learnItemDao.replaceByQueryKey(entities, queryKey) + learnItemDao.replaceByQueryKey(items.map { it.toEntity(queryKey) }, queryKey) syncMetadataDao.upsert( HorizonSyncMetadataEntity( - dataType = SyncDataType.LEARN_MY_CONTENT_ITEMS, + dataType = syncDataTypeFor(queryKey), lastSyncedAtMs = System.currentTimeMillis(), ) ) } + private fun syncDataTypeFor(queryKey: String): SyncDataType = when (queryKey) { + QUERY_KEY_IN_PROGRESS -> SyncDataType.LEARN_MY_CONTENT_IN_PROGRESS + QUERY_KEY_COMPLETED -> SyncDataType.LEARN_MY_CONTENT_COMPLETED + else -> throw IllegalArgumentException("Unknown queryKey: $queryKey") + } + + companion object { + const val QUERY_KEY_IN_PROGRESS = "IN_PROGRESS" + const val QUERY_KEY_COMPLETED = "COMPLETED" + private const val PAGE_SIZE = 4 + } + private fun HorizonLearnItemEntity.toModel(): LearnItem { return when (itemType) { LearnItemType.PROGRAM.name -> ProgramEnrollmentItem( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnMyContentRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnMyContentRepository.kt index e07f8171ca..7abcc0425a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnMyContentRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnMyContentRepository.kt @@ -16,6 +16,7 @@ package com.instructure.horizon.data.repository import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.mycontent.LearnItem import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse @@ -44,16 +45,34 @@ class LearnMyContentRepository @Inject constructor( ): LearnItemsResponse { return if (shouldFetchFromNetwork()) { networkDataSource.getLearnItems(cursor, searchQuery, sortBy, status, itemTypes, forceRefresh) - .also { response -> + .also { if (shouldSync() && cursor == null) { - localDataSource.saveLearnItems(response.items, queryKey) + depaginateAndSync(status, queryKey) } } } else { - localDataSource.getLearnItems(queryKey) + localDataSource.getLearnItems(queryKey, searchQuery, sortBy, itemTypes, cursor) } } + private suspend fun depaginateAndSync(status: List?, queryKey: String) { + val allItems = mutableListOf() + var nextCursor: String? = null + do { + val page = networkDataSource.getLearnItems( + cursor = nextCursor, + searchQuery = null, + sortBy = null, + status = status, + itemTypes = null, + forceRefresh = true, + ) + allItems.addAll(page.items) + nextCursor = if (page.pageInfo.hasNextPage) page.pageInfo.nextCursor else null + } while (nextCursor != null) + localDataSource.saveLearnItems(allItems, queryKey) + } + override suspend fun sync() { TODO("Not yet implemented") } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt index 6629b76089..694948bd4b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt @@ -69,7 +69,7 @@ import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity HorizonLocalFileEntity::class, HorizonFileFolderEntity::class, ], - version = 5, + version = 6, ) abstract class HorizonDatabase : RoomDatabase() { abstract fun dashboardEnrollmentDao(): HorizonDashboardEnrollmentDao diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt index d821b3b816..d1b21c3dbc 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt @@ -22,7 +22,8 @@ enum class SyncDataType { DASHBOARD_ENROLLMENTS, DASHBOARD_PROGRAMS, DASHBOARD_MODULE_ITEMS, - LEARN_MY_CONTENT_ITEMS, + LEARN_MY_CONTENT_IN_PROGRESS, + LEARN_MY_CONTENT_COMPLETED, LEARN_SAVED_ITEMS, LEARN_LIBRARY_COLLECTIONS, COURSE_DETAILS, From 4133a02eb36ea2576696f79a0ddf6f76bea8b9f5 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 8 Apr 2026 15:37:01 +0200 Subject: [PATCH 16/31] Fix tests --- .../fakes/FakeGetHorizonCourseManager.kt | 28 +++ .../espresso/HorizonOfflineTestModule.kt | 88 ++++++++ .../horizon/espresso/TestModule.kt | 10 +- .../HorizonDashboardInteractionTest.kt | 2 +- .../dashboard/DashboardViewModelTest.kt | 8 +- .../course/DashboardCourseRepositoryTest.kt | 191 ------------------ .../course/DashboardCourseViewModelTest.kt | 149 +++++--------- 7 files changed, 183 insertions(+), 293 deletions(-) create mode 100644 libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt delete mode 100644 libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseRepositoryTest.kt 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..0fa01ae6cf 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,33 @@ class FakeGetHorizonCourseManager(): HorizonGetCoursesManager { ) } + override suspend fun getDashboardEnrollments( + userId: Long, + forceNetwork: Boolean + ): DataResult> { + 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 * 100.0, + ) + } + return DataResult.Success(dashboardEnrollments) + } + override suspend fun getProgramCourses( courseId: Long, forceNetwork: Boolean diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt new file mode 100644 index 0000000000..2162cfaf56 --- /dev/null +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt @@ -0,0 +1,88 @@ +/* + * 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.horizon.database.HorizonDatabase +import com.instructure.horizon.database.dao.HorizonCourseModuleDao +import com.instructure.horizon.database.dao.HorizonCourseScoreDao +import com.instructure.horizon.database.dao.HorizonDashboardEnrollmentDao +import com.instructure.horizon.database.dao.HorizonDashboardModuleItemDao +import com.instructure.horizon.database.dao.HorizonDashboardProgramDao +import com.instructure.horizon.database.dao.HorizonFileFolderDao +import com.instructure.horizon.database.dao.HorizonLearnCollectionDao +import com.instructure.horizon.database.dao.HorizonLearnCourseDao +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.HorizonSyncMetadataDao +import com.instructure.horizon.di.HorizonOfflineModule +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 provideHorizonDashboardProgramDao(db: HorizonDatabase): HorizonDashboardProgramDao = db.dashboardProgramDao() + + @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 provideHorizonLearnCourseDao(db: HorizonDatabase): HorizonLearnCourseDao = db.learnCourseDao() + + @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() +} 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..f0a3516ad8 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,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 @@ -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 diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/dashboard/HorizonDashboardInteractionTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/dashboard/HorizonDashboardInteractionTest.kt index 9cb58cdcce..c8feb9d349 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/dashboard/HorizonDashboardInteractionTest.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/dashboard/HorizonDashboardInteractionTest.kt @@ -105,7 +105,7 @@ class HorizonDashboardInteractionTest: HorizonTest() { dashboardPage.assertCourseCardDisplayed( course1.name, listOf(programs[0].name), - fakeGetHorizonCourseManager.getCourses().first().progress, + fakeGetHorizonCourseManager.getCourses().first().progress * 100, moduleItem1.title ) } 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..d44f808e14 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/DashboardViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/DashboardViewModelTest.kt @@ -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 @@ -44,6 +47,9 @@ class DashboardViewModelTest { private val themePrefs: ThemePrefs = mockk(relaxed = true) private val localeUtils: LocaleUtils = mockk(relaxed = true) private val dashboardEventHandler: DashboardEventHandler = DashboardEventHandler() + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val notificationCounts = listOf( @@ -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 index b383942e32..2640b46a69 100644 --- 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 @@ -17,25 +17,23 @@ package com.instructure.horizon.features.dashboard.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.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.domain.usecase.DashboardCoursesData +import com.instructure.horizon.domain.usecase.GetDashboardCoursesUseCase 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.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider 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 @@ -47,81 +45,52 @@ 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 getDashboardCoursesUseCase: GetDashboardCoursesUseCase = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = 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( + DashboardEnrollment( + enrollmentId = 1L, + enrollmentState = DashboardEnrollment.STATE_ACTIVE, + courseId = 1L, + courseName = "Course 1", + courseImageUrl = "url_1", + courseSyllabus = "syllabus 1", + institutionName = "Account 1", + completionPercentage = 0.0 ), - ) - 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] + DashboardEnrollment( + enrollmentId = 2L, + enrollmentState = DashboardEnrollment.STATE_ACTIVE, + courseId = 2L, + courseName = "Course 2", + courseImageUrl = null, + courseSyllabus = null, + institutionName = null, + completionPercentage = 0.0 ), ) - private val invitedEnrollments = listOf( - GetCoursesQuery.Enrollment( - id = "3", - state = EnrollmentWorkflowState.invited, - lastActivityAt = Date(), - course = courses[2] + private val completedEnrollments = listOf( + DashboardEnrollment( + enrollmentId = 4L, + enrollmentState = DashboardEnrollment.STATE_COMPLETED, + courseId = 4L, + courseName = "Course 4", + courseImageUrl = null, + courseSyllabus = null, + institutionName = null, + completionPercentage = 100.0 ) ) - private val completedEnrollments = listOf( - GetCoursesQuery.Enrollment( - id = "4", - state = EnrollmentWorkflowState.completed, - lastActivityAt = Date(), - course = courses[3] - ) - ) - private val programs = listOf( - Program( // Not started Program + private val programs = listOf( + Program( id = "1", name = "Program 1", description = "Program 1 description", @@ -130,7 +99,7 @@ class DashboardCourseViewModelTest { variant = ProgramVariantType.LINEAR, sortedRequirements = emptyList() ), - Program( // Program with Course 2 + Program( id = "2", name = "Program 2", description = "Program 2 description", @@ -149,22 +118,6 @@ class DashboardCourseViewModelTest { ) ) ) - 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() { @@ -173,10 +126,12 @@ class DashboardCourseViewModelTest { 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 + coEvery { getDashboardCoursesUseCase.invoke() } returns DashboardCoursesData( + enrollments = activeEnrollments + completedEnrollments, + programs = programs, + unenrolledPrograms = listOf(programs[0]), + nextModuleItemByCourseId = emptyMap() + ) } @After @@ -187,7 +142,6 @@ class DashboardCourseViewModelTest { @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) @@ -195,17 +149,16 @@ class DashboardCourseViewModelTest { 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) } + fun `Test use case is invoked on initialization`() { + getViewModel() + coVerify { getDashboardCoursesUseCase.invoke() } } private fun getViewModel(): DashboardCourseViewModel { - return DashboardCourseViewModel(context, repository, dashboardEventHandler) + return DashboardCourseViewModel(context, getDashboardCoursesUseCase, dashboardEventHandler, networkStateProvider, featureFlagProvider) } -} \ No newline at end of file +} From 02a5fb79cf1601ff860672bf3cafbad186217468 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Thu, 9 Apr 2026 15:11:19 +0200 Subject: [PATCH 17/31] Unify list/detail models --- .../espresso/HorizonOfflineTestModule.kt | 8 +- .../CourseDetailsLocalDataSource.kt | 89 +++++-- .../LearnMyContentLocalDataSource.kt | 217 +++++++++++------- .../ProgramDetailsLocalDataSource.kt | 61 +++-- .../data/datasource/ProgramLocalDataSource.kt | 21 +- .../horizon/database/HorizonDatabase.kt | 22 +- .../horizon/database/dao/HorizonCourseDao.kt | 74 ++++++ .../database/dao/HorizonLearnCourseDao.kt | 38 --- ...oardProgramDao.kt => HorizonProgramDao.kt} | 30 +-- ...CourseEntity.kt => HorizonCourseEntity.kt} | 23 +- .../database/entity/HorizonLearnItemEntity.kt | 27 +-- ...ourseRef.kt => HorizonProgramCourseRef.kt} | 4 +- ...ogramEntity.kt => HorizonProgramEntity.kt} | 16 +- .../horizon/di/HorizonOfflineModule.kt | 12 +- 14 files changed, 395 insertions(+), 247 deletions(-) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseDao.kt delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCourseDao.kt rename libs/horizon/src/main/java/com/instructure/horizon/database/dao/{HorizonDashboardProgramDao.kt => HorizonProgramDao.kt} (58%) rename libs/horizon/src/main/java/com/instructure/horizon/database/entity/{HorizonLearnCourseEntity.kt => HorizonCourseEntity.kt} (57%) rename libs/horizon/src/main/java/com/instructure/horizon/database/entity/{HorizonDashboardProgramCourseRef.kt => HorizonProgramCourseRef.kt} (90%) rename libs/horizon/src/main/java/com/instructure/horizon/database/entity/{HorizonDashboardProgramEntity.kt => HorizonProgramEntity.kt} (64%) diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt index 2162cfaf56..bbf80f7068 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt @@ -18,17 +18,17 @@ package com.instructure.horizon.espresso import android.content.Context import androidx.room.Room import com.instructure.horizon.database.HorizonDatabase +import com.instructure.horizon.database.dao.HorizonCourseDao import com.instructure.horizon.database.dao.HorizonCourseModuleDao import com.instructure.horizon.database.dao.HorizonCourseScoreDao import com.instructure.horizon.database.dao.HorizonDashboardEnrollmentDao import com.instructure.horizon.database.dao.HorizonDashboardModuleItemDao -import com.instructure.horizon.database.dao.HorizonDashboardProgramDao import com.instructure.horizon.database.dao.HorizonFileFolderDao import com.instructure.horizon.database.dao.HorizonLearnCollectionDao -import com.instructure.horizon.database.dao.HorizonLearnCourseDao import com.instructure.horizon.database.dao.HorizonLearnItemDao import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.horizon.database.dao.HorizonProgramDao import com.instructure.horizon.database.dao.HorizonSyncMetadataDao import com.instructure.horizon.di.HorizonOfflineModule import dagger.Module @@ -54,7 +54,7 @@ object HorizonOfflineTestModule { fun provideHorizonDashboardEnrollmentDao(db: HorizonDatabase): HorizonDashboardEnrollmentDao = db.dashboardEnrollmentDao() @Provides - fun provideHorizonDashboardProgramDao(db: HorizonDatabase): HorizonDashboardProgramDao = db.dashboardProgramDao() + fun provideHorizonProgramDao(db: HorizonDatabase): HorizonProgramDao = db.programDao() @Provides fun provideHorizonDashboardModuleItemDao(db: HorizonDatabase): HorizonDashboardModuleItemDao = db.dashboardModuleItemDao() @@ -72,7 +72,7 @@ object HorizonOfflineTestModule { fun provideHorizonLearnSavedItemDao(db: HorizonDatabase): HorizonLearnSavedItemDao = db.learnSavedItemDao() @Provides - fun provideHorizonLearnCourseDao(db: HorizonDatabase): HorizonLearnCourseDao = db.learnCourseDao() + fun provideHorizonCourseDao(db: HorizonDatabase): HorizonCourseDao = db.courseDao() @Provides fun provideHorizonCourseModuleDao(db: HorizonDatabase): HorizonCourseModuleDao = db.courseModuleDao() diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt index 96e347e531..fb33a7e61c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt @@ -18,13 +18,13 @@ package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement -import com.instructure.horizon.database.dao.HorizonDashboardProgramDao -import com.instructure.horizon.database.dao.HorizonLearnCourseDao -import com.instructure.horizon.database.entity.HorizonDashboardProgramCourseRef -import com.instructure.horizon.database.entity.HorizonDashboardProgramEntity -import com.instructure.horizon.database.entity.HorizonLearnCourseEntity -import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity +import com.instructure.horizon.database.dao.HorizonCourseDao +import com.instructure.horizon.database.dao.HorizonProgramDao import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonCourseEntity +import com.instructure.horizon.database.entity.HorizonProgramCourseRef +import com.instructure.horizon.database.entity.HorizonProgramEntity +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity import com.instructure.horizon.database.entity.SyncDataType import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus import com.instructure.journey.type.ProgramVariantType @@ -32,13 +32,13 @@ import java.util.Date import javax.inject.Inject class CourseDetailsLocalDataSource @Inject constructor( - private val learnCourseDao: HorizonLearnCourseDao, - private val programDao: HorizonDashboardProgramDao, + private val courseDao: HorizonCourseDao, + private val programDao: HorizonProgramDao, private val syncMetadataDao: HorizonSyncMetadataDao, ) { suspend fun getCourse(courseId: Long): CourseWithProgress { - val entity = learnCourseDao.getByCourseId(courseId) + val entity = courseDao.getByCourseId(courseId) ?: throw IllegalStateException("Course $courseId not found in cache") return entity.toCourseWithProgress() } @@ -53,7 +53,31 @@ class CourseDetailsLocalDataSource @Inject constructor( } suspend fun saveCourseDetails(course: CourseWithProgress, programs: List) { - learnCourseDao.insertAll(listOf(course.toEntity())) + courseDao.insertIfAbsent(listOf(course.toDefaultEntity())) + courseDao.updateCourseDetailsFields( + courseId = course.courseId, + name = course.courseName, + progress = course.progress, + imageUrl = course.courseImageUrl, + courseSyllabus = course.courseSyllabus, + ) + val programEntities = programs.map { it.toEntity() } + val refs = programs.flatMap { program -> + program.sortedRequirements.mapIndexed { index, req -> + HorizonProgramCourseRef( + programId = program.id, + courseId = req.courseId, + requirementId = req.id, + progressId = req.progressId, + required = req.required, + progress = req.progress, + enrollmentStatus = req.enrollmentStatus?.rawValue, + sortOrder = index, + ) + } + } + programDao.insertAll(programEntities) + programDao.insertAllRefs(refs) syncMetadataDao.upsert( HorizonSyncMetadataEntity( dataType = SyncDataType.COURSE_DETAILS, @@ -62,29 +86,54 @@ class CourseDetailsLocalDataSource @Inject constructor( ) } - private fun HorizonLearnCourseEntity.toCourseWithProgress(): CourseWithProgress { + private fun HorizonCourseEntity.toCourseWithProgress(): CourseWithProgress { return CourseWithProgress( courseId = courseId, - courseName = courseName, - courseImageUrl = null, + courseName = name, + courseImageUrl = imageUrl, courseSyllabus = courseSyllabus, progress = progress, ) } - private fun CourseWithProgress.toEntity(): HorizonLearnCourseEntity { - return HorizonLearnCourseEntity( + private fun CourseWithProgress.toDefaultEntity(): HorizonCourseEntity { + return HorizonCourseEntity( courseId = courseId, - courseName = courseName, + name = courseName, progress = progress, + imageUrl = courseImageUrl, + startAtMs = null, + endAtMs = null, + requirementCount = null, + requirementCompletedCount = null, + completedAtMs = null, + grade = null, + workflowState = null, + lastActivityAtMs = null, + enrolledAtMs = null, courseSyllabus = courseSyllabus, - startDateMs = null, - endDateMs = null, moduleItemsDurations = "", ) } - private fun HorizonDashboardProgramEntity.toProgram(refs: List): Program { + private fun Program.toEntity(): HorizonProgramEntity { + return HorizonProgramEntity( + programId = id, + name = name, + description = description, + startDateMs = startDate?.time, + endDateMs = endDate?.time, + variant = variant.rawValue, + estimatedDurationMinutes = null, + courseCount = sortedRequirements.size, + courseCompletionCount = courseCompletionCount, + enrolledAtMs = null, + completionPercentage = null, + enrollmentStatus = null, + ) + } + + private fun HorizonProgramEntity.toProgram(refs: List): Program { val requirements = refs.sortedBy { it.sortOrder }.map { ref -> ProgramRequirement( id = ref.requirementId, @@ -99,7 +148,7 @@ class CourseDetailsLocalDataSource @Inject constructor( } return Program( id = programId, - name = programName, + name = name, description = description, startDate = startDateMs?.let { Date(it) }, endDate = endDateMs?.let { Date(it) }, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt index 82f4dc63cd..315e402205 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt @@ -22,9 +22,13 @@ import com.instructure.canvasapi2.models.journey.mycontent.LearnItem import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse import com.instructure.canvasapi2.models.journey.mycontent.ProgramEnrollmentItem +import com.instructure.horizon.database.dao.HorizonCourseDao import com.instructure.horizon.database.dao.HorizonLearnItemDao +import com.instructure.horizon.database.dao.HorizonProgramDao import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonCourseEntity import com.instructure.horizon.database.entity.HorizonLearnItemEntity +import com.instructure.horizon.database.entity.HorizonProgramEntity import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity import com.instructure.horizon.database.entity.SyncDataType import java.util.Date @@ -32,6 +36,8 @@ import javax.inject.Inject class LearnMyContentLocalDataSource @Inject constructor( private val learnItemDao: HorizonLearnItemDao, + private val courseDao: HorizonCourseDao, + private val programDao: HorizonProgramDao, private val syncMetadataDao: HorizonSyncMetadataDao, ) { @@ -42,7 +48,21 @@ class LearnMyContentLocalDataSource @Inject constructor( itemTypes: List?, cursor: String?, ): LearnItemsResponse { - var items = learnItemDao.getByQueryKey(queryKey).map { it.toModel() } + val listItems = learnItemDao.getByQueryKey(queryKey) + + var items: List = listItems.mapNotNull { listItem -> + when (listItem.itemType) { + LearnItemType.COURSE.name -> { + val entity = courseDao.getByCourseId(listItem.id.toLong()) ?: return@mapNotNull null + entity.toCourseEnrollmentItem(listItem.position) + } + LearnItemType.PROGRAM.name -> { + val entity = programDao.getById(listItem.id) ?: return@mapNotNull null + entity.toProgramEnrollmentItem(listItem.position) + } + else -> null + } + } if (!itemTypes.isNullOrEmpty()) { items = items.filter { item -> @@ -84,7 +104,33 @@ class LearnMyContentLocalDataSource @Inject constructor( } suspend fun saveLearnItems(items: List, queryKey: String) { - learnItemDao.replaceByQueryKey(items.map { it.toEntity(queryKey) }, queryKey) + items.forEach { item -> + when (item) { + is CourseEnrollmentItem -> { + val courseId = item.id.toLong() + courseDao.insertIfAbsent(listOf(item.toDefaultEntity())) + courseDao.updateEnrollmentFields( + courseId = courseId, + name = item.name, + progress = item.completionPercentage ?: 0.0, + imageUrl = item.imageUrl, + startAtMs = item.startAt?.time, + endAtMs = item.endAt?.time, + requirementCount = item.requirementCount, + requirementCompletedCount = item.requirementCompletedCount, + completedAtMs = item.completedAt?.time, + grade = item.grade, + workflowState = item.workflowState, + lastActivityAtMs = item.lastActivityAt?.time, + enrolledAtMs = item.enrolledAt?.time, + ) + } + is ProgramEnrollmentItem -> { + programDao.insertAll(listOf(item.toDefaultEntity())) + } + } + } + learnItemDao.replaceByQueryKey(items.map { it.toListEntity(queryKey) }, queryKey) syncMetadataDao.upsert( HorizonSyncMetadataEntity( dataType = syncDataTypeFor(queryKey), @@ -105,93 +151,88 @@ class LearnMyContentLocalDataSource @Inject constructor( private const val PAGE_SIZE = 4 } - private fun HorizonLearnItemEntity.toModel(): LearnItem { - return when (itemType) { - LearnItemType.PROGRAM.name -> ProgramEnrollmentItem( - id = id, - name = name, - position = position, - enrolledAt = enrolledAtMs?.let { Date(it) }, - completionPercentage = completionPercentage, - startDate = startDateMs?.let { Date(it) }, - endDate = endDateMs?.let { Date(it) }, - status = enrollmentStatus.orEmpty(), - description = description, - variant = variant.orEmpty(), - estimatedDurationMinutes = estimatedDurationMinutes, - courseCount = courseCount ?: 0, - ) - else -> CourseEnrollmentItem( - id = id, - name = name, - position = position, - enrolledAt = enrolledAtMs?.let { Date(it) }, - completionPercentage = completionPercentage, - startAt = startAtMs?.let { Date(it) }, - endAt = endAtMs?.let { Date(it) }, - requirementCount = requirementCount, - requirementCompletedCount = requirementCompletedCount, - completedAt = completedAtMs?.let { Date(it) }, - grade = grade, - imageUrl = imageUrl, - workflowState = workflowState.orEmpty(), - lastActivityAt = lastActivityAtMs?.let { Date(it) }, - ) - } + private fun HorizonCourseEntity.toCourseEnrollmentItem(position: Int): CourseEnrollmentItem { + return CourseEnrollmentItem( + id = courseId.toString(), + name = name, + position = position, + enrolledAt = enrolledAtMs?.let { Date(it) }, + completionPercentage = progress, + startAt = startAtMs?.let { Date(it) }, + endAt = endAtMs?.let { Date(it) }, + requirementCount = requirementCount, + requirementCompletedCount = requirementCompletedCount, + completedAt = completedAtMs?.let { Date(it) }, + grade = grade, + imageUrl = imageUrl, + workflowState = workflowState.orEmpty(), + lastActivityAt = lastActivityAtMs?.let { Date(it) }, + ) } - private fun LearnItem.toEntity(queryKey: String): HorizonLearnItemEntity { - return when (this) { - is ProgramEnrollmentItem -> HorizonLearnItemEntity( - id = id, - queryKey = queryKey, - itemType = LearnItemType.PROGRAM.name, - name = name, - position = position, - enrolledAtMs = enrolledAt?.time, - completionPercentage = completionPercentage, - startDateMs = startDate?.time, - endDateMs = endDate?.time, - enrollmentStatus = status, - description = description, - variant = variant, - estimatedDurationMinutes = estimatedDurationMinutes, - courseCount = courseCount, - startAtMs = null, - endAtMs = null, - requirementCount = null, - requirementCompletedCount = null, - completedAtMs = null, - grade = null, - imageUrl = null, - workflowState = null, - lastActivityAtMs = null, - ) - is CourseEnrollmentItem -> HorizonLearnItemEntity( - id = id, - queryKey = queryKey, - itemType = LearnItemType.COURSE.name, - name = name, - position = position, - enrolledAtMs = enrolledAt?.time, - completionPercentage = completionPercentage, - startDateMs = null, - endDateMs = null, - enrollmentStatus = null, - description = null, - variant = null, - estimatedDurationMinutes = null, - courseCount = null, - startAtMs = startAt?.time, - endAtMs = endAt?.time, - requirementCount = requirementCount, - requirementCompletedCount = requirementCompletedCount, - completedAtMs = completedAt?.time, - grade = grade, - imageUrl = imageUrl, - workflowState = workflowState, - lastActivityAtMs = lastActivityAt?.time, - ) - } + private fun HorizonProgramEntity.toProgramEnrollmentItem(position: Int): ProgramEnrollmentItem { + return ProgramEnrollmentItem( + id = programId, + name = name, + position = position, + enrolledAt = enrolledAtMs?.let { Date(it) }, + completionPercentage = completionPercentage, + startDate = startDateMs?.let { Date(it) }, + endDate = endDateMs?.let { Date(it) }, + status = enrollmentStatus.orEmpty(), + description = description, + variant = variant, + estimatedDurationMinutes = estimatedDurationMinutes, + courseCount = courseCount ?: 0, + ) + } + + private fun CourseEnrollmentItem.toDefaultEntity(): HorizonCourseEntity { + return HorizonCourseEntity( + courseId = id.toLong(), + name = name, + progress = completionPercentage ?: 0.0, + imageUrl = imageUrl, + startAtMs = startAt?.time, + endAtMs = endAt?.time, + requirementCount = requirementCount, + requirementCompletedCount = requirementCompletedCount, + completedAtMs = completedAt?.time, + grade = grade, + workflowState = workflowState, + lastActivityAtMs = lastActivityAt?.time, + enrolledAtMs = enrolledAt?.time, + courseSyllabus = null, + moduleItemsDurations = "", + ) + } + + private fun ProgramEnrollmentItem.toDefaultEntity(): HorizonProgramEntity { + return HorizonProgramEntity( + programId = id, + name = name, + description = description, + startDateMs = startDate?.time, + endDateMs = endDate?.time, + variant = variant, + estimatedDurationMinutes = estimatedDurationMinutes, + courseCount = courseCount, + courseCompletionCount = null, + enrolledAtMs = enrolledAt?.time, + completionPercentage = completionPercentage, + enrollmentStatus = status, + ) + } + + private fun LearnItem.toListEntity(queryKey: String): HorizonLearnItemEntity { + return HorizonLearnItemEntity( + id = id, + queryKey = queryKey, + itemType = when (this) { + is CourseEnrollmentItem -> LearnItemType.COURSE.name + is ProgramEnrollmentItem -> LearnItemType.PROGRAM.name + }, + position = position, + ) } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt index b0661ee44c..66093a2e9f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt @@ -18,37 +18,48 @@ package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement -import com.instructure.horizon.database.dao.HorizonDashboardProgramDao -import com.instructure.horizon.database.dao.HorizonLearnCourseDao -import com.instructure.horizon.database.entity.HorizonDashboardProgramCourseRef -import com.instructure.horizon.database.entity.HorizonDashboardProgramEntity -import com.instructure.horizon.database.entity.HorizonLearnCourseEntity +import com.instructure.horizon.database.dao.HorizonCourseDao +import com.instructure.horizon.database.dao.HorizonProgramDao +import com.instructure.horizon.database.entity.HorizonCourseEntity +import com.instructure.horizon.database.entity.HorizonProgramCourseRef +import com.instructure.horizon.database.entity.HorizonProgramEntity import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus import com.instructure.journey.type.ProgramVariantType import java.util.Date import javax.inject.Inject class ProgramDetailsLocalDataSource @Inject constructor( - private val programDao: HorizonDashboardProgramDao, - private val learnCourseDao: HorizonLearnCourseDao, + private val programDao: HorizonProgramDao, + private val courseDao: HorizonCourseDao, ) { suspend fun getProgramDetails(programId: String): Program { - val entity = programDao.getAll().find { it.programId == programId } + val entity = programDao.getById(programId) ?: throw IllegalArgumentException("Program with id $programId not found in cache") val refs = programDao.getRefsForProgram(programId) return entity.toProgram(refs) } suspend fun getCoursesById(courseIds: List): List { - return learnCourseDao.getByCourseIds(courseIds).map { it.toCourseWithModuleItemDurations() } + return courseDao.getByCourseIds(courseIds).map { it.toCourseWithModuleItemDurations() } } suspend fun saveCourses(courses: List) { - learnCourseDao.insertAll(courses.map { it.toEntity() }) + courses.forEach { course -> + courseDao.insertIfAbsent(listOf(course.toDefaultEntity())) + courseDao.updateProgramCourseFields( + courseId = course.courseId, + name = course.courseName, + startAtMs = course.startDate?.time, + endAtMs = course.endDate?.time, + moduleItemsDurations = course.moduleItemsDuration.joinToString(","), + ) + } } - private fun HorizonDashboardProgramEntity.toProgram(refs: List): Program { + private fun HorizonProgramEntity.toProgram( + refs: List + ): Program { val requirements = refs.sortedBy { it.sortOrder }.map { ref -> ProgramRequirement( id = ref.requirementId, @@ -63,7 +74,7 @@ class ProgramDetailsLocalDataSource @Inject constructor( } return Program( id = programId, - name = programName, + name = name, description = description, startDate = startDateMs?.let { Date(it) }, endDate = endDateMs?.let { Date(it) }, @@ -73,24 +84,32 @@ class ProgramDetailsLocalDataSource @Inject constructor( ) } - private fun HorizonLearnCourseEntity.toCourseWithModuleItemDurations(): CourseWithModuleItemDurations { + private fun HorizonCourseEntity.toCourseWithModuleItemDurations(): CourseWithModuleItemDurations { return CourseWithModuleItemDurations( courseId = courseId, - courseName = courseName, + courseName = name, moduleItemsDuration = if (moduleItemsDurations.isEmpty()) emptyList() else moduleItemsDurations.split(","), - startDate = startDateMs?.let { Date(it) }, - endDate = endDateMs?.let { Date(it) }, + startDate = startAtMs?.let { Date(it) }, + endDate = endAtMs?.let { Date(it) }, ) } - private fun CourseWithModuleItemDurations.toEntity(): HorizonLearnCourseEntity { - return HorizonLearnCourseEntity( + private fun CourseWithModuleItemDurations.toDefaultEntity(): HorizonCourseEntity { + return HorizonCourseEntity( courseId = courseId, - courseName = courseName, + name = courseName, progress = 0.0, + imageUrl = null, + startAtMs = startDate?.time, + endAtMs = endDate?.time, + requirementCount = null, + requirementCompletedCount = null, + completedAtMs = null, + grade = null, + workflowState = null, + lastActivityAtMs = null, + enrolledAtMs = null, courseSyllabus = null, - startDateMs = startDate?.time, - endDateMs = endDate?.time, moduleItemsDurations = moduleItemsDuration.joinToString(","), ) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt index 20c4ba6cb4..63af3b1476 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt @@ -17,16 +17,16 @@ package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement -import com.instructure.horizon.database.dao.HorizonDashboardProgramDao -import com.instructure.horizon.database.entity.HorizonDashboardProgramCourseRef -import com.instructure.horizon.database.entity.HorizonDashboardProgramEntity +import com.instructure.horizon.database.dao.HorizonProgramDao +import com.instructure.horizon.database.entity.HorizonProgramCourseRef +import com.instructure.horizon.database.entity.HorizonProgramEntity import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus import com.instructure.journey.type.ProgramVariantType import java.util.Date import javax.inject.Inject class ProgramLocalDataSource @Inject constructor( - private val programDao: HorizonDashboardProgramDao, + private val programDao: HorizonProgramDao, ) { suspend fun getPrograms(): List { @@ -34,7 +34,7 @@ class ProgramLocalDataSource @Inject constructor( val refs = programDao.getRefsForProgram(programEntity.programId) Program( id = programEntity.programId, - name = programEntity.programName, + name = programEntity.name, description = programEntity.description, startDate = programEntity.startDateMs?.let { Date(it) }, endDate = programEntity.endDateMs?.let { Date(it) }, @@ -58,21 +58,26 @@ class ProgramLocalDataSource @Inject constructor( suspend fun savePrograms(programs: List, enrolledCourseIds: Set) { val programEntities = programs.map { program -> - HorizonDashboardProgramEntity( + HorizonProgramEntity( programId = program.id, - programName = program.name, + name = program.name, description = program.description, startDateMs = program.startDate?.time, endDateMs = program.endDate?.time, variant = program.variant.rawValue, + estimatedDurationMinutes = null, + courseCount = program.sortedRequirements.size, courseCompletionCount = program.courseCompletionCount, + enrolledAtMs = null, + completionPercentage = null, + enrollmentStatus = null, ) } val refs = programs.flatMap { program -> program.sortedRequirements .filter { it.courseId in enrolledCourseIds } .mapIndexed { index, req -> - HorizonDashboardProgramCourseRef( + HorizonProgramCourseRef( programId = program.id, courseId = req.courseId, requirementId = req.id, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt index 694948bd4b..715a2523f9 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt @@ -18,49 +18,49 @@ package com.instructure.horizon.database import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import com.instructure.horizon.database.dao.HorizonCourseDao import com.instructure.horizon.database.dao.HorizonCourseModuleDao import com.instructure.horizon.database.dao.HorizonCourseScoreDao import com.instructure.horizon.database.dao.HorizonDashboardEnrollmentDao import com.instructure.horizon.database.dao.HorizonDashboardModuleItemDao -import com.instructure.horizon.database.dao.HorizonDashboardProgramDao import com.instructure.horizon.database.dao.HorizonFileFolderDao import com.instructure.horizon.database.dao.HorizonLearnCollectionDao -import com.instructure.horizon.database.dao.HorizonLearnCourseDao import com.instructure.horizon.database.dao.HorizonLearnItemDao import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.horizon.database.dao.HorizonProgramDao import com.instructure.horizon.database.dao.HorizonSyncMetadataDao import com.instructure.horizon.database.entity.HorizonCourseAssignmentEntity import com.instructure.horizon.database.entity.HorizonCourseAssignmentGroupEntity +import com.instructure.horizon.database.entity.HorizonCourseEntity import com.instructure.horizon.database.entity.HorizonCourseGradeEntity import com.instructure.horizon.database.entity.HorizonCourseModuleEntity import com.instructure.horizon.database.entity.HorizonCourseModuleItemEntity import com.instructure.horizon.database.entity.HorizonDashboardEnrollmentEntity import com.instructure.horizon.database.entity.HorizonDashboardModuleItemEntity -import com.instructure.horizon.database.entity.HorizonDashboardProgramCourseRef -import com.instructure.horizon.database.entity.HorizonDashboardProgramEntity import com.instructure.horizon.database.entity.HorizonFileFolderEntity import com.instructure.horizon.database.entity.HorizonLearnCollectionEntity import com.instructure.horizon.database.entity.HorizonLearnCollectionItemEntity -import com.instructure.horizon.database.entity.HorizonLearnCourseEntity import com.instructure.horizon.database.entity.HorizonLearnItemEntity import com.instructure.horizon.database.entity.HorizonLearnSavedItemEntity import com.instructure.horizon.database.entity.HorizonLocalFileEntity +import com.instructure.horizon.database.entity.HorizonProgramCourseRef +import com.instructure.horizon.database.entity.HorizonProgramEntity import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity @TypeConverters(HorizonTypeConverters::class) @Database( entities = [ HorizonDashboardEnrollmentEntity::class, - HorizonDashboardProgramEntity::class, - HorizonDashboardProgramCourseRef::class, + HorizonProgramEntity::class, + HorizonProgramCourseRef::class, HorizonDashboardModuleItemEntity::class, HorizonSyncMetadataEntity::class, HorizonLearnItemEntity::class, HorizonLearnCollectionEntity::class, HorizonLearnCollectionItemEntity::class, HorizonLearnSavedItemEntity::class, - HorizonLearnCourseEntity::class, + HorizonCourseEntity::class, HorizonCourseModuleEntity::class, HorizonCourseModuleItemEntity::class, HorizonCourseAssignmentGroupEntity::class, @@ -69,17 +69,17 @@ import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity HorizonLocalFileEntity::class, HorizonFileFolderEntity::class, ], - version = 6, + version = 7, ) abstract class HorizonDatabase : RoomDatabase() { abstract fun dashboardEnrollmentDao(): HorizonDashboardEnrollmentDao - abstract fun dashboardProgramDao(): HorizonDashboardProgramDao + abstract fun programDao(): HorizonProgramDao abstract fun dashboardModuleItemDao(): HorizonDashboardModuleItemDao abstract fun syncMetadataDao(): HorizonSyncMetadataDao abstract fun learnItemDao(): HorizonLearnItemDao abstract fun learnCollectionDao(): HorizonLearnCollectionDao abstract fun learnSavedItemDao(): HorizonLearnSavedItemDao - abstract fun learnCourseDao(): HorizonLearnCourseDao + abstract fun courseDao(): HorizonCourseDao abstract fun courseModuleDao(): HorizonCourseModuleDao abstract fun courseScoreDao(): HorizonCourseScoreDao abstract fun localFileDao(): HorizonLocalFileDao diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseDao.kt new file mode 100644 index 0000000000..fbb185ae39 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseDao.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.horizon.database.entity.HorizonCourseEntity + +@Dao +interface HorizonCourseDao { + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertIfAbsent(courses: List) + + @Query(""" + UPDATE horizon_courses SET + name = :name, progress = :progress, imageUrl = :imageUrl, + startAtMs = :startAtMs, endAtMs = :endAtMs, + requirementCount = :requirementCount, + requirementCompletedCount = :requirementCompletedCount, + completedAtMs = :completedAtMs, grade = :grade, + workflowState = :workflowState, lastActivityAtMs = :lastActivityAtMs, + enrolledAtMs = :enrolledAtMs + WHERE courseId = :courseId + """) + suspend fun updateEnrollmentFields( + courseId: Long, name: String, progress: Double, imageUrl: String?, + startAtMs: Long?, endAtMs: Long?, + requirementCount: Int?, requirementCompletedCount: Int?, + completedAtMs: Long?, grade: Double?, + workflowState: String?, lastActivityAtMs: Long?, enrolledAtMs: Long?, + ) + + @Query(""" + UPDATE horizon_courses SET + name = :name, progress = :progress, imageUrl = :imageUrl, + courseSyllabus = :courseSyllabus + WHERE courseId = :courseId + """) + suspend fun updateCourseDetailsFields( + courseId: Long, name: String, progress: Double, imageUrl: String?, courseSyllabus: String?, + ) + + @Query(""" + UPDATE horizon_courses SET + name = :name, startAtMs = :startAtMs, endAtMs = :endAtMs, + moduleItemsDurations = :moduleItemsDurations + WHERE courseId = :courseId + """) + suspend fun updateProgramCourseFields( + courseId: Long, name: String, startAtMs: Long?, endAtMs: Long?, moduleItemsDurations: String, + ) + + @Query("SELECT * FROM horizon_courses WHERE courseId = :courseId") + suspend fun getByCourseId(courseId: Long): HorizonCourseEntity? + + @Query("SELECT * FROM horizon_courses WHERE courseId IN (:courseIds)") + suspend fun getByCourseIds(courseIds: List): List +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCourseDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCourseDao.kt deleted file mode 100644 index 68c5d2519d..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCourseDao.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.instructure.horizon.database.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.instructure.horizon.database.entity.HorizonLearnCourseEntity - -@Dao -interface HorizonLearnCourseDao { - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAll(courses: List) - - @Query("SELECT * FROM horizon_learn_courses WHERE courseId = :courseId") - suspend fun getByCourseId(courseId: Long): HorizonLearnCourseEntity? - - @Query("SELECT * FROM horizon_learn_courses WHERE courseId IN (:courseIds)") - suspend fun getByCourseIds(courseIds: List): List - - @Query("DELETE FROM horizon_learn_courses") - suspend fun deleteAll() -} 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/HorizonProgramDao.kt similarity index 58% rename from libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardProgramDao.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonProgramDao.kt index 701b10e33a..bb695b1c6d 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardProgramDao.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonProgramDao.kt @@ -20,35 +20,35 @@ 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 +import com.instructure.horizon.database.entity.HorizonProgramCourseRef +import com.instructure.horizon.database.entity.HorizonProgramEntity @Dao -interface HorizonDashboardProgramDao { +interface HorizonProgramDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAll(programs: List) + suspend fun insertAll(programs: List) @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAllRefs(refs: List) + suspend fun insertAllRefs(refs: List) - @Query("SELECT * FROM horizon_dashboard_programs") - suspend fun getAll(): List + @Query("SELECT * FROM horizon_programs") + suspend fun getAll(): List - @Query("SELECT * FROM horizon_dashboard_program_course_refs WHERE programId = :programId") - suspend fun getRefsForProgram(programId: String): List + @Query("SELECT * FROM horizon_programs WHERE programId = :programId") + suspend fun getById(programId: String): HorizonProgramEntity? - @Query("DELETE FROM horizon_dashboard_programs") + @Query("SELECT * FROM horizon_program_course_refs WHERE programId = :programId") + suspend fun getRefsForProgram(programId: String): List + + @Query("DELETE FROM horizon_programs") suspend fun deleteAll() - @Query("DELETE FROM horizon_dashboard_program_course_refs") + @Query("DELETE FROM horizon_program_course_refs") suspend fun deleteAllRefs() @Transaction - suspend fun replaceAll( - programs: List, - refs: List, - ) { + suspend fun replaceAll(programs: List, refs: List) { deleteAllRefs() deleteAll() insertAll(programs) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCourseEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseEntity.kt similarity index 57% rename from libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCourseEntity.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseEntity.kt index a5066d9568..75d3179f9e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCourseEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseEntity.kt @@ -19,16 +19,25 @@ import androidx.room.Entity import androidx.room.PrimaryKey /** - * Stores course data needed by the Program Details and Course Details screens offline. - * [moduleItemsDurations] is stored as a comma-separated list of ISO 8601 duration strings. + * Unified entity for course data, used by both the My Content list screen and the Course/Program + * Details screens. Fields from the list screen (enrollment metadata) and from the details screens + * (syllabus, module durations) are merged here to ensure data consistency when offline. */ -@Entity(tableName = "horizon_learn_courses") -data class HorizonLearnCourseEntity( +@Entity(tableName = "horizon_courses") +data class HorizonCourseEntity( @PrimaryKey val courseId: Long, - val courseName: String, + val name: String, val progress: Double, + val imageUrl: String?, + val startAtMs: Long?, + val endAtMs: Long?, + val requirementCount: Int?, + val requirementCompletedCount: Int?, + val completedAtMs: Long?, + val grade: Double?, + val workflowState: String?, + val lastActivityAtMs: Long?, + val enrolledAtMs: Long?, val courseSyllabus: String?, - val startDateMs: Long?, - val endDateMs: Long?, val moduleItemsDurations: String, ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnItemEntity.kt index 299045e375..c38173a502 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnItemEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnItemEntity.kt @@ -20,9 +20,9 @@ import androidx.room.Index import androidx.room.PrimaryKey /** - * Flattened entity for both [com.instructure.canvasapi2.models.journey.mycontent.ProgramEnrollmentItem] - * and [com.instructure.canvasapi2.models.journey.mycontent.CourseEnrollmentItem]. - * [queryKey] identifies which fetch bucket this item belongs to (e.g. "IN_PROGRESS" or "COMPLETED"). + * Lightweight list-metadata entity. Stores the ordering and bucketing information for items + * shown on the My Content screen. Domain data (course/program fields) lives in the unified + * [HorizonCourseEntity] and [HorizonProgramEntity] tables, keyed by [id]. */ @Entity( tableName = "horizon_learn_items", @@ -32,26 +32,5 @@ data class HorizonLearnItemEntity( @PrimaryKey val id: String, val queryKey: String, val itemType: String, - val name: String, val position: Int, - val enrolledAtMs: Long?, - val completionPercentage: Double?, - // ProgramEnrollmentItem-specific fields - val startDateMs: Long?, - val endDateMs: Long?, - val enrollmentStatus: String?, - val description: String?, - val variant: String?, - val estimatedDurationMinutes: Int?, - val courseCount: Int?, - // CourseEnrollmentItem-specific fields - val startAtMs: Long?, - val endAtMs: Long?, - val requirementCount: Int?, - val requirementCompletedCount: Int?, - val completedAtMs: Long?, - val grade: Double?, - val imageUrl: String?, - val workflowState: String?, - val lastActivityAtMs: Long?, ) 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/HorizonProgramCourseRef.kt similarity index 90% rename from libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramCourseRef.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonProgramCourseRef.kt index d2da522403..3c4363c32b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramCourseRef.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonProgramCourseRef.kt @@ -18,10 +18,10 @@ package com.instructure.horizon.database.entity import androidx.room.Entity @Entity( - tableName = "horizon_dashboard_program_course_refs", + tableName = "horizon_program_course_refs", primaryKeys = ["programId", "courseId"] ) -data class HorizonDashboardProgramCourseRef( +data class HorizonProgramCourseRef( val programId: String, val courseId: Long, val requirementId: String, 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/HorizonProgramEntity.kt similarity index 64% rename from libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramEntity.kt rename to libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonProgramEntity.kt index e6fa5d7d35..13eb45aa8a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonProgramEntity.kt @@ -18,13 +18,23 @@ package com.instructure.horizon.database.entity import androidx.room.Entity import androidx.room.PrimaryKey -@Entity(tableName = "horizon_dashboard_programs") -data class HorizonDashboardProgramEntity( +/** + * Unified entity for program data, used by both the My Content list screen and the Program + * Details screen. Merges fields from [ProgramEnrollmentItem] (list) and the + * [Program] domain model (details) to ensure data consistency when offline. + */ +@Entity(tableName = "horizon_programs") +data class HorizonProgramEntity( @PrimaryKey val programId: String, - val programName: String, + val name: String, val description: String?, val startDateMs: Long?, val endDateMs: Long?, val variant: String, + val estimatedDurationMinutes: Int?, + val courseCount: Int?, val courseCompletionCount: Int?, + val enrolledAtMs: Long?, + val completionPercentage: Double?, + val enrollmentStatus: String?, ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt index b59caf2a31..a01487096c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt @@ -18,17 +18,17 @@ 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.HorizonCourseDao import com.instructure.horizon.database.dao.HorizonCourseModuleDao import com.instructure.horizon.database.dao.HorizonCourseScoreDao import com.instructure.horizon.database.dao.HorizonDashboardEnrollmentDao import com.instructure.horizon.database.dao.HorizonDashboardModuleItemDao -import com.instructure.horizon.database.dao.HorizonDashboardProgramDao import com.instructure.horizon.database.dao.HorizonFileFolderDao import com.instructure.horizon.database.dao.HorizonLearnCollectionDao -import com.instructure.horizon.database.dao.HorizonLearnCourseDao import com.instructure.horizon.database.dao.HorizonLearnItemDao import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.horizon.database.dao.HorizonProgramDao import com.instructure.horizon.database.dao.HorizonSyncMetadataDao import dagger.Module import dagger.Provides @@ -54,8 +54,8 @@ class HorizonOfflineModule { } @Provides - fun provideHorizonDashboardProgramDao(db: HorizonDatabase): HorizonDashboardProgramDao { - return db.dashboardProgramDao() + fun provideHorizonProgramDao(db: HorizonDatabase): HorizonProgramDao { + return db.programDao() } @Provides @@ -84,8 +84,8 @@ class HorizonOfflineModule { } @Provides - fun provideHorizonLearnCourseDao(db: HorizonDatabase): HorizonLearnCourseDao { - return db.learnCourseDao() + fun provideHorizonCourseDao(db: HorizonDatabase): HorizonCourseDao { + return db.courseDao() } @Provides From d6756a62747f292ab432b5cceed46fd71228727d Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Fri, 10 Apr 2026 09:49:54 +0200 Subject: [PATCH 18/31] Improvements --- .../CourseDetailsLocalDataSource.kt | 2 +- .../data/repository/CourseRepository.kt | 2 +- .../database/HorizonDatabaseProvider.kt | 21 ++++++++++++++++--- .../entity/HorizonFileFolderEntity.kt | 2 +- .../database/entity/HorizonLocalFileEntity.kt | 2 +- .../horizon/di/HorizonOfflineModule.kt | 3 +-- .../instructure/horizon/offline/SyncPolicy.kt | 21 ------------------- 7 files changed, 23 insertions(+), 30 deletions(-) delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/offline/SyncPolicy.kt diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt index fb33a7e61c..4c2e942f56 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt @@ -48,7 +48,7 @@ class CourseDetailsLocalDataSource @Inject constructor( return allPrograms.mapNotNull { programEntity -> val refs = programDao.getRefsForProgram(programEntity.programId) val program = programEntity.toProgram(refs) - if (program.sortedRequirements.firstOrNull()?.courseId == courseId) program else null + if (program.sortedRequirements.any { it.courseId == courseId }) program else null } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt index bb675ad33d..1c81b22376 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt @@ -45,7 +45,7 @@ class CourseRepository @Inject constructor( .also { course -> if (shouldSync()) { val parsedSyllabus = htmlParser.createHtmlStringWithLocalFiles(course.courseSyllabus, course.courseId) - val programs = courseDetailsNetworkDataSource.getProgramsForCourse(courseId, forceRefresh = false) + val programs = courseDetailsNetworkDataSource.getProgramsForCourse(courseId, forceRefresh) courseDetailsLocalDataSource.saveCourseDetails(course.copy(courseSyllabus = parsedSyllabus.htmlWithLocalFileLinks), programs) fileSyncRepository.syncHtmlFiles(course.courseId, parsedSyllabus) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabaseProvider.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabaseProvider.kt index aef8525cb3..dbe0554aca 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabaseProvider.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabaseProvider.kt @@ -17,21 +17,36 @@ package com.instructure.horizon.database import android.content.Context import androidx.room.Room +import com.google.firebase.crashlytics.FirebaseCrashlytics import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton +private const val HORIZON_DB_PREFIX = "horizon-db-" + @Singleton class HorizonDatabaseProvider @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val firebaseCrashlytics: FirebaseCrashlytics, ) { private val dbMap = mutableMapOf() - fun getDatabase(userId: Long): HorizonDatabase { + @Synchronized + fun getDatabase(userId: Long?): HorizonDatabase { + if (userId == null) { + firebaseCrashlytics.recordException(IllegalStateException("Cannot access Horizon database while logged out")) + return Room.inMemoryDatabaseBuilder(context, HorizonDatabase::class.java).build() + } return dbMap.getOrPut(userId) { - Room.databaseBuilder(context, HorizonDatabase::class.java, "horizon-db-$userId") + Room.databaseBuilder(context, HorizonDatabase::class.java, "$HORIZON_DB_PREFIX$userId") .fallbackToDestructiveMigration() .build() } } + + fun clearDatabase(userId: Long) { + getDatabase(userId).clearAllTables() + dbMap.remove(userId) + context.deleteDatabase("$HORIZON_DB_PREFIX$userId") + } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt index ffd69f3ce0..674e199da9 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt @@ -18,7 +18,7 @@ package com.instructure.horizon.database.entity import androidx.room.Entity import androidx.room.PrimaryKey -@Entity +@Entity(tableName = "HorizonFileFolderEntity") data class HorizonFileFolderEntity( @PrimaryKey val id: Long, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLocalFileEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLocalFileEntity.kt index 5cf881df94..71d88fa512 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLocalFileEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLocalFileEntity.kt @@ -19,7 +19,7 @@ import androidx.room.Entity import androidx.room.PrimaryKey import java.util.Date -@Entity +@Entity(tableName = "HorizonLocalFileEntity") data class HorizonLocalFileEntity( @PrimaryKey val id: Long, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt index a01487096c..324f8e223c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt @@ -44,8 +44,7 @@ class HorizonOfflineModule { provider: HorizonDatabaseProvider, apiPrefs: ApiPrefs, ): HorizonDatabase { - val userId = apiPrefs.user?.id ?: -1L - return provider.getDatabase(userId) + return provider.getDatabase(apiPrefs.user?.id) } @Provides 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 deleted file mode 100644 index 0495edce88..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/offline/SyncPolicy.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.instructure.horizon.offline - -enum class SyncPolicy { - ALWAYS_REPLACE, - SKIP_IF_PRESENT, -} From 144f16dbf88bd0729742d58bb7ac1ac120f87a27 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Fri, 10 Apr 2026 11:21:24 +0200 Subject: [PATCH 19/31] Implement improvements --- .../ProgramDetailsLocalDataSource.kt | 30 +++++++++++ .../data/repository/CourseRepository.kt | 3 +- .../data/repository/ProgramRepository.kt | 1 + .../horizon/database/dao/HorizonProgramDao.kt | 10 ++++ .../horizon/di/HorizonOfflineModule.kt | 12 +++++ .../horizon/di/HorizonQualifiers.kt | 22 ++++++++ .../offline/HorizonHtmlParserFileSource.kt | 46 ++++++++++++++++ .../pandautils/di/ApplicationModule.kt | 4 +- .../features/offline/sync/HtmlParser.kt | 26 +++------ .../offline/sync/HtmlParserFileSource.kt | 53 +++++++++++++++++++ .../pandautils/utils/FeatureFlagProvider.kt | 2 +- 11 files changed, 186 insertions(+), 23 deletions(-) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/di/HorizonQualifiers.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonHtmlParserFileSource.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParserFileSource.kt diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt index 66093a2e9f..1505609749 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt @@ -44,6 +44,36 @@ class ProgramDetailsLocalDataSource @Inject constructor( return courseDao.getByCourseIds(courseIds).map { it.toCourseWithModuleItemDurations() } } + suspend fun saveProgramDetails(program: Program) { + val entity = HorizonProgramEntity( + programId = program.id, + name = program.name, + description = program.description, + startDateMs = program.startDate?.time, + endDateMs = program.endDate?.time, + variant = program.variant.rawValue, + estimatedDurationMinutes = null, + courseCount = program.sortedRequirements.size, + courseCompletionCount = program.courseCompletionCount, + enrolledAtMs = null, + completionPercentage = null, + enrollmentStatus = null, + ) + val refs = program.sortedRequirements.mapIndexed { index, req -> + HorizonProgramCourseRef( + programId = program.id, + courseId = req.courseId, + requirementId = req.id, + progressId = req.progressId, + required = req.required, + progress = req.progress, + enrollmentStatus = req.enrollmentStatus?.rawValue, + sortOrder = index, + ) + } + programDao.upsertProgram(entity, refs) + } + suspend fun saveCourses(courses: List) { courses.forEach { course -> courseDao.insertIfAbsent(listOf(course.toDefaultEntity())) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt index 1c81b22376..78f6027d03 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt @@ -22,6 +22,7 @@ import com.instructure.horizon.data.datasource.CourseDetailsLocalDataSource import com.instructure.horizon.data.datasource.CourseDetailsNetworkDataSource import com.instructure.horizon.data.datasource.CourseProgressLocalDataSource import com.instructure.horizon.data.datasource.CourseProgressNetworkDataSource +import com.instructure.horizon.di.HorizonHtmlParserQualifier import com.instructure.horizon.offline.OfflineSyncRepository import com.instructure.pandautils.features.offline.sync.HtmlParser import com.instructure.pandautils.utils.FeatureFlagProvider @@ -33,7 +34,7 @@ class CourseRepository @Inject constructor( private val courseDetailsLocalDataSource: CourseDetailsLocalDataSource, private val courseProgressNetworkDataSource: CourseProgressNetworkDataSource, private val courseProgressLocalDataSource: CourseProgressLocalDataSource, - private val htmlParser: HtmlParser, + @HorizonHtmlParserQualifier private val htmlParser: HtmlParser, private val fileSyncRepository: HorizonFileSyncRepository, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt index dd9e6476c5..3d4167c5ef 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt @@ -54,6 +54,7 @@ class ProgramRepository @Inject constructor( suspend fun getProgramDetails(programId: String, forceRefresh: Boolean = false): Program { return if (shouldFetchFromNetwork()) { programDetailsNetworkDataSource.getProgramDetails(programId, forceRefresh) + .also { if (shouldSync()) programDetailsLocalDataSource.saveProgramDetails(it) } } else { programDetailsLocalDataSource.getProgramDetails(programId) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonProgramDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonProgramDao.kt index bb695b1c6d..edc3bddac3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonProgramDao.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonProgramDao.kt @@ -47,6 +47,9 @@ interface HorizonProgramDao { @Query("DELETE FROM horizon_program_course_refs") suspend fun deleteAllRefs() + @Query("DELETE FROM horizon_program_course_refs WHERE programId = :programId") + suspend fun deleteRefsForProgram(programId: String) + @Transaction suspend fun replaceAll(programs: List, refs: List) { deleteAllRefs() @@ -54,4 +57,11 @@ interface HorizonProgramDao { insertAll(programs) insertAllRefs(refs) } + + @Transaction + suspend fun upsertProgram(program: HorizonProgramEntity, refs: List) { + insertAll(listOf(program)) + deleteRefsForProgram(program.programId) + insertAllRefs(refs) + } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt index 324f8e223c..5d12b56932 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt @@ -15,9 +15,13 @@ */ package com.instructure.horizon.di +import android.content.Context import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.horizon.database.HorizonDatabase import com.instructure.horizon.database.HorizonDatabaseProvider +import com.instructure.horizon.offline.HorizonHtmlParserFileSource +import com.instructure.pandautils.features.offline.sync.HtmlParser +import dagger.hilt.android.qualifiers.ApplicationContext import com.instructure.horizon.database.dao.HorizonCourseDao import com.instructure.horizon.database.dao.HorizonCourseModuleDao import com.instructure.horizon.database.dao.HorizonCourseScoreDao @@ -106,4 +110,12 @@ class HorizonOfflineModule { fun provideHorizonFileFolderDao(db: HorizonDatabase): HorizonFileFolderDao { return db.fileFolderDao() } + + @Provides + @HorizonHtmlParserQualifier + fun provideHorizonHtmlParser( + fileSource: HorizonHtmlParserFileSource, + apiPrefs: ApiPrefs, + @ApplicationContext context: Context, + ): HtmlParser = HtmlParser(fileSource, apiPrefs, context) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonQualifiers.kt b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonQualifiers.kt new file mode 100644 index 0000000000..f1add781f2 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonQualifiers.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class HorizonHtmlParserQualifier diff --git a/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonHtmlParserFileSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonHtmlParserFileSource.kt new file mode 100644 index 0000000000..69db908ec8 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonHtmlParserFileSource.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.offline + +import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.horizon.database.dao.HorizonFileFolderDao +import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.pandautils.features.offline.sync.HtmlParserFileSource +import javax.inject.Inject + +class HorizonHtmlParserFileSource @Inject constructor( + private val localFileDao: HorizonLocalFileDao, + private val fileFolderDao: HorizonFileFolderDao, + private val fileFolderApi: FileFolderAPI.FilesFoldersInterface, +) : HtmlParserFileSource { + + override suspend fun findLocalFilePath(fileId: Long): String? { + return localFileDao.findById(fileId)?.path + } + + override suspend fun findDisplayName(fileId: Long, courseId: Long): String? { + fileFolderDao.findById(fileId)?.displayName?.takeIf { it.isNotEmpty() }?.let { return it } + return fileFolderApi.getCourseFile( + courseId, fileId, + RestParams(isForceReadFromNetwork = false, shouldLoginOnTokenError = false) + ).dataOrNull?.displayName + } + + // HorizonFileSyncRepository.syncHtmlFiles already skips files that are in alreadyDownloadedIds, + // so all files should be passed through for sync consideration. + override suspend fun isRegisteredForSync(fileId: Long): Boolean = false +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/ApplicationModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/ApplicationModule.kt index 21ed42e399..3c05173b09 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/ApplicationModule.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/ApplicationModule.kt @@ -33,6 +33,7 @@ import com.instructure.canvasapi2.utils.RemoteConfigPrefs import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.pandautils.dialogs.RatingDialog import com.instructure.pandautils.features.offline.sync.HtmlParser +import com.instructure.pandautils.features.offline.sync.OfflineHtmlParserFileSource import com.instructure.pandautils.room.offline.daos.FileFolderDao import com.instructure.pandautils.room.offline.daos.FileSyncSettingsDao import com.instructure.pandautils.room.offline.daos.LocalFileDao @@ -137,7 +138,8 @@ class ApplicationModule { fileSyncSettingsDao: FileSyncSettingsDao, fileFolderApi: FileFolderAPI.FilesFoldersInterface ): HtmlParser { - return HtmlParser(localFileDao, apiPrefs, fileFolderDao, context, fileSyncSettingsDao, fileFolderApi) + val fileSource = OfflineHtmlParserFileSource(localFileDao, fileFolderDao, fileSyncSettingsDao, fileFolderApi) + return HtmlParser(fileSource, apiPrefs, context) } @Provides diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt index 7cd8d9a0ea..e61f86e686 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt @@ -18,23 +18,15 @@ package com.instructure.pandautils.features.offline.sync import android.content.Context import androidx.core.net.toUri -import com.instructure.canvasapi2.apis.FileFolderAPI -import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.StudioMediaMetadata import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.pandautils.room.offline.daos.FileFolderDao -import com.instructure.pandautils.room.offline.daos.FileSyncSettingsDao -import com.instructure.pandautils.room.offline.daos.LocalFileDao import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File class HtmlParser( - private var localFileDao: LocalFileDao, + private val fileSource: HtmlParserFileSource, private val apiPrefs: ApiPrefs, - private val fileFolderDao: FileFolderDao, @ApplicationContext private val context: Context, - private val fileSyncSettingsDao: FileSyncSettingsDao, - private val fileFolderApi: FileFolderAPI.FilesFoldersInterface ) { private val imageRegex = Regex("]*src=\"([^\"]*)\"[^>]*>") @@ -108,12 +100,12 @@ class HtmlParser( var resultHtml = html var shouldSyncFile = false - val filePath = localFileDao.findById(fileId)?.path + val filePath = fileSource.findLocalFilePath(fileId) if (!filePath.isNullOrEmpty()) { resultHtml = resultHtml.replace(imageUrl, "file://$filePath") } else { resultHtml = resultHtml.replace(imageUrl, "file://${createLocalFilePath(fileId, courseId)}") - if (fileSyncSettingsDao.findById(fileId) == null) { + if (!fileSource.isRegisteredForSync(fileId)) { shouldSyncFile = true } } @@ -122,16 +114,10 @@ class HtmlParser( } private suspend fun createLocalFilePath(fileId: Long, courseId: Long): String { - var fileName = fileFolderDao.findById(fileId)?.displayName.orEmpty() - if (fileName.isEmpty()) { - val file = fileFolderApi.getCourseFile(courseId, fileId, RestParams(isForceReadFromNetwork = false, shouldLoginOnTokenError = false)).dataOrNull - fileName = file?.displayName.orEmpty() - } + val fileName = fileSource.findDisplayName(fileId, courseId).orEmpty() val fileNameWithId = if (fileName.isNotEmpty()) "${fileId}_$fileName" else "$fileId" val dir = File(context.filesDir, apiPrefs.user?.id.toString()) - - val downloadedFile = File(dir, fileNameWithId) - return downloadedFile.absolutePath + return File(dir, fileNameWithId).absolutePath } private fun createLocalFilePathForExternalFile(fileName: String, courseId: Long): String { @@ -149,7 +135,7 @@ class HtmlParser( val fileUrl = match.groupValues[1] val fileId = internalFileRegex.find(fileUrl)?.groupValues?.get(1)?.toLongOrNull() if (fileId != null) { - if (fileSyncSettingsDao.findById(fileId) == null) { + if (!fileSource.isRegisteredForSync(fileId)) { internalFileIds.add(fileId) } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParserFileSource.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParserFileSource.kt new file mode 100644 index 0000000000..95ac537778 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParserFileSource.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.pandautils.features.offline.sync + +import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.pandautils.room.offline.daos.FileFolderDao +import com.instructure.pandautils.room.offline.daos.FileSyncSettingsDao +import com.instructure.pandautils.room.offline.daos.LocalFileDao + +interface HtmlParserFileSource { + suspend fun findLocalFilePath(fileId: Long): String? + suspend fun findDisplayName(fileId: Long, courseId: Long): String? + suspend fun isRegisteredForSync(fileId: Long): Boolean +} + +class OfflineHtmlParserFileSource( + private val localFileDao: LocalFileDao, + private val fileFolderDao: FileFolderDao, + private val fileSyncSettingsDao: FileSyncSettingsDao, + private val fileFolderApi: FileFolderAPI.FilesFoldersInterface, +) : HtmlParserFileSource { + + override suspend fun findLocalFilePath(fileId: Long): String? { + return localFileDao.findById(fileId)?.path + } + + override suspend fun findDisplayName(fileId: Long, courseId: Long): String? { + fileFolderDao.findById(fileId)?.displayName?.takeIf { it.isNotEmpty() }?.let { return it } + return fileFolderApi.getCourseFile( + courseId, fileId, + RestParams(isForceReadFromNetwork = false, shouldLoginOnTokenError = false) + ).dataOrNull?.displayName + } + + override suspend fun isRegisteredForSync(fileId: Long): Boolean { + return fileSyncSettingsDao.findById(fileId) != null + } +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt index 14df17cf46..8d493122ff 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt @@ -53,7 +53,7 @@ class FeatureFlagProvider( } suspend fun offlineEnabled(): Boolean { - return checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary + return true//checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary } private suspend fun checkEnvironmentFeatureFlag(featureFlag: String): Boolean { From 9ad5faee614dd6629a7d8ecd7218576b2b336368 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Fri, 10 Apr 2026 11:43:50 +0200 Subject: [PATCH 20/31] Improvements --- .../fakes/FakeGetHorizonCourseManager.kt | 6 +++--- .../journey/learninglibrary/CollectionItemType.kt | 6 +++++- .../dashboard/HorizonDashboardInteractionTest.kt | 2 +- .../LearnLearningLibraryLocalDataSource.kt | 14 ++++++++------ .../pandautils/utils/FeatureFlagProvider.kt | 2 +- 5 files changed, 18 insertions(+), 12 deletions(-) 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 0fa01ae6cf..3c6fe255d5 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 @@ -111,7 +111,7 @@ class FakeGetHorizonCourseManager(): HorizonGetCoursesManager { courseImageUrl = course.courseImageUrl, courseSyllabus = course.courseSyllabus, institutionName = null, - completionPercentage = course.progress * 100.0, + completionPercentage = course.progress, ) } return DataResult.Success(dashboardEnrollments) @@ -140,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) { @@ -149,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) { diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/journey/learninglibrary/CollectionItemType.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/journey/learninglibrary/CollectionItemType.kt index 3bbd7968cc..114928eb0f 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/journey/learninglibrary/CollectionItemType.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/journey/learninglibrary/CollectionItemType.kt @@ -26,7 +26,11 @@ enum class CollectionItemType { EXTERNAL_URL, EXTERNAL_TOOL, FILE, - PROGRAM + PROGRAM; + + companion object { + fun safeValueOf(name: String): CollectionItemType? = entries.find { it.name == name } + } } fun ApolloCollectionItemType.toModel(): CollectionItemType { diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/dashboard/HorizonDashboardInteractionTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/dashboard/HorizonDashboardInteractionTest.kt index c8feb9d349..9cb58cdcce 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/dashboard/HorizonDashboardInteractionTest.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/dashboard/HorizonDashboardInteractionTest.kt @@ -105,7 +105,7 @@ class HorizonDashboardInteractionTest: HorizonTest() { dashboardPage.assertCourseCardDisplayed( course1.name, listOf(programs[0].name), - fakeGetHorizonCourseManager.getCourses().first().progress * 100, + fakeGetHorizonCourseManager.getCourses().first().progress, moduleItem1.title ) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryLocalDataSource.kt index 7dde693cf3..86bdb26e0c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryLocalDataSource.kt @@ -42,7 +42,7 @@ class LearnLearningLibraryLocalDataSource @Inject constructor( suspend fun getEnrolledLearningLibraries(): List { val collections = collectionDao.getAllCollections() return collections.map { collection -> - val items = collectionDao.getItemsByCollectionId(collection.id).map { it.toModel() } + val items = collectionDao.getItemsByCollectionId(collection.id).mapNotNull { it.toModel() } collection.toModel(items) } } @@ -62,7 +62,7 @@ class LearnLearningLibraryLocalDataSource @Inject constructor( } suspend fun getSavedItems(): LearningLibraryCollectionItemsResponse { - val items = savedItemDao.getAll().map { it.toModel() } + val items = savedItemDao.getAll().mapNotNull { it.toModel() } return LearningLibraryCollectionItemsResponse( items = items, pageInfo = LearningLibraryPageInfo( @@ -100,7 +100,8 @@ class LearnLearningLibraryLocalDataSource @Inject constructor( ) } - private fun HorizonLearnCollectionItemEntity.toModel(): LearningLibraryCollectionItem { + private fun HorizonLearnCollectionItemEntity.toModel(): LearningLibraryCollectionItem? { + val resolvedItemType = CollectionItemType.safeValueOf(itemType) ?: return null val canvasCourse = if (canvasCourseId != null && canvasUrl != null) { CanvasCourseInfo( courseId = canvasCourseId, @@ -123,7 +124,7 @@ class LearnLearningLibraryLocalDataSource @Inject constructor( return LearningLibraryCollectionItem( id = id, libraryId = libraryId, - itemType = CollectionItemType.valueOf(itemType), + itemType = resolvedItemType, displayOrder = displayOrder, canvasCourse = canvasCourse, moduleInfo = moduleInfo, @@ -138,7 +139,8 @@ class LearnLearningLibraryLocalDataSource @Inject constructor( ) } - private fun HorizonLearnSavedItemEntity.toModel(): LearningLibraryCollectionItem { + private fun HorizonLearnSavedItemEntity.toModel(): LearningLibraryCollectionItem? { + val resolvedItemType = CollectionItemType.safeValueOf(itemType) ?: return null val canvasCourse = if (canvasCourseId != null && canvasUrl != null) { CanvasCourseInfo( courseId = canvasCourseId, @@ -161,7 +163,7 @@ class LearnLearningLibraryLocalDataSource @Inject constructor( return LearningLibraryCollectionItem( id = id, libraryId = libraryId, - itemType = CollectionItemType.valueOf(itemType), + itemType = resolvedItemType, displayOrder = displayOrder, canvasCourse = canvasCourse, moduleInfo = moduleInfo, diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt index 8d493122ff..14df17cf46 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt @@ -53,7 +53,7 @@ class FeatureFlagProvider( } suspend fun offlineEnabled(): Boolean { - return true//checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary + return checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary } private suspend fun checkEnvironmentFeatureFlag(featureFlag: String): Boolean { From e0d76be9f4e88a2fe888fdf0b499c73c92c39804 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Fri, 10 Apr 2026 14:10:22 +0200 Subject: [PATCH 21/31] Trigger CI From 594e6d0c2cfedec16ced0913858565b5212ec7bb Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Mon, 13 Apr 2026 10:56:58 +0200 Subject: [PATCH 22/31] Fix tests --- .../espresso/HorizonOfflineTestModule.kt | 12 ++++++ .../features/offline/sync/HtmlParserTest.kt | 37 ++++--------------- 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt index bbf80f7068..37d71c03a2 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt @@ -17,6 +17,7 @@ package com.instructure.horizon.espresso import android.content.Context import androidx.room.Room +import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.horizon.database.HorizonDatabase import com.instructure.horizon.database.dao.HorizonCourseDao import com.instructure.horizon.database.dao.HorizonCourseModuleDao @@ -30,7 +31,10 @@ import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao import com.instructure.horizon.database.dao.HorizonLocalFileDao import com.instructure.horizon.database.dao.HorizonProgramDao import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.di.HorizonHtmlParserQualifier import com.instructure.horizon.di.HorizonOfflineModule +import com.instructure.horizon.offline.HorizonHtmlParserFileSource +import com.instructure.pandautils.features.offline.sync.HtmlParser import dagger.Module import dagger.Provides import dagger.hilt.android.qualifiers.ApplicationContext @@ -85,4 +89,12 @@ object HorizonOfflineTestModule { @Provides fun provideHorizonFileFolderDao(db: HorizonDatabase): HorizonFileFolderDao = db.fileFolderDao() + + @Provides + @HorizonHtmlParserQualifier + fun provideHorizonHtmlParser( + fileSource: HorizonHtmlParserFileSource, + apiPrefs: ApiPrefs, + @ApplicationContext context: Context, + ): HtmlParser = HtmlParser(fileSource, apiPrefs, context) } diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/HtmlParserTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/HtmlParserTest.kt index 1aec9d2768..b9e62324cc 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/HtmlParserTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/HtmlParserTest.kt @@ -18,17 +18,9 @@ package com.instructure.pandautils.features.offline.sync import android.content.Context import android.net.Uri -import com.instructure.canvasapi2.apis.FileFolderAPI -import com.instructure.canvasapi2.models.FileFolder import com.instructure.canvasapi2.models.StudioCaption import com.instructure.canvasapi2.models.StudioMediaMetadata import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.canvasapi2.utils.DataResult -import com.instructure.pandautils.room.offline.daos.FileFolderDao -import com.instructure.pandautils.room.offline.daos.FileSyncSettingsDao -import com.instructure.pandautils.room.offline.daos.LocalFileDao -import com.instructure.pandautils.room.offline.entities.FileSyncSettingsEntity -import com.instructure.pandautils.room.offline.entities.LocalFileEntity import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -42,18 +34,14 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.io.File -import java.util.Date class HtmlParserTest { - private var localFileDao: LocalFileDao = mockk(relaxed = true) + private val fileSource: HtmlParserFileSource = mockk(relaxed = true) private val apiPrefs: ApiPrefs = mockk(relaxed = true) - private val fileFolderDao: FileFolderDao = mockk(relaxed = true) private val context: Context = mockk(relaxed = true) - private val fileSyncSettingsDao: FileSyncSettingsDao = mockk(relaxed = true) - private val fileFolderApi: FileFolderAPI.FilesFoldersInterface = mockk(relaxed = true) - private val htmlParser = HtmlParser(localFileDao, apiPrefs, fileFolderDao, context, fileSyncSettingsDao, fileFolderApi) + private val htmlParser = HtmlParser(fileSource, apiPrefs, context) @Before fun setup() { @@ -67,6 +55,7 @@ class HtmlParserTest { mockk() { every { lastPathSegment } returns url.split("/").last() every { scheme } returns "https" + every { isRelative } returns false } } } @@ -102,7 +91,7 @@ class HtmlParserTest { "
" + "\"\"" - coEvery { localFileDao.findById(123456) } returns LocalFileEntity(123456, 1L, Date(), "/files/1/123456_filename.jpg") + coEvery { fileSource.findLocalFilePath(123456) } returns "/files/1/123456_filename.jpg" val result = htmlParser.createHtmlStringWithLocalFiles(html, 1L) val expectedHtml = "

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

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

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

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

Internal public:

\n" + "

\"image2.png\"

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

File not synced:

\n" + "

file.pdf

" - coEvery { fileSyncSettingsDao.findById(1234) } returns FileSyncSettingsEntity(1234, "name", 1L, "") - coEvery { fileSyncSettingsDao.findById(678) } returns null + coEvery { fileSource.isRegisteredForSync(1234) } returns true val result = htmlParser.createHtmlStringWithLocalFiles(html, 1L) @@ -406,8 +386,7 @@ class HtmlParserTest { """.trimIndent() - coEvery { localFileDao.findById(123456) } returns LocalFileEntity(123456, 1L, Date(), "/files/1/123456_internal.jpg") - coEvery { fileSyncSettingsDao.findById(789) } returns null + coEvery { fileSource.findLocalFilePath(123456) } returns "/files/1/123456_internal.jpg" val studioMetaData = listOf( StudioMediaMetadata(1, "video-old", "Old Video", "video/mp4", 1000, emptyList(), "https://studio/media/video-old"), From 98e843aeb158d5cfcf94d6dc53079494569fb8ef Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Mon, 13 Apr 2026 18:40:39 +0200 Subject: [PATCH 23/31] Refactor architecture --- .../AssignmentDetailsLocalDataSource.kt | 70 ++++++++ .../AssignmentDetailsNetworkDataSource.kt | 31 ++++ .../CourseProgressNetworkDataSource.kt | 22 ++- .../datasource/FileContentLocalDataSource.kt | 58 +++++++ .../FileContentNetworkDataSource.kt} | 19 ++- .../data/datasource/PageLocalDataSource.kt | 51 ++++++ .../data/datasource/PageNetworkDataSource.kt | 32 ++++ .../repository/AssignmentDetailsRepository.kt | 55 ++++++ .../data/repository/FileContentRepository.kt | 82 +++++++++ .../repository/HorizonFileSyncRepository.kt | 4 + .../horizon/data/repository/PageRepository.kt | 55 ++++++ .../horizon/database/HorizonDatabase.kt | 10 +- .../dao/HorizonAssignmentDetailsDao.kt | 32 ++++ .../database/dao/HorizonCourseModuleDao.kt | 6 + .../horizon/database/dao/HorizonPageDao.kt | 32 ++++ .../entity/HorizonAssignmentDetailsEntity.kt | 49 ++++++ .../entity/HorizonFileFolderEntity.kt | 2 + .../database/entity/HorizonPageEntity.kt | 37 ++++ .../horizon/di/HorizonOfflineModule.kt | 12 ++ .../usecase/GetAssignmentDetailsUseCase.kt | 36 ++++ .../domain/usecase/GetFileDetailsUseCase.kt | 35 ++++ .../usecase/GetModulesWithItemsUseCase.kt | 35 ++++ .../domain/usecase/GetPageDetailsUseCase.kt | 36 ++++ .../ModuleItemSequenceRepository.kt | 153 ++++++++++------- .../ModuleItemSequenceViewModel.kt | 4 +- .../assessment/AssessmentRepository.kt | 45 ----- .../content/assessment/AssessmentViewModel.kt | 20 ++- .../assignment/AssignmentDetailsRepository.kt | 51 ------ .../assignment/AssignmentDetailsViewModel.kt | 24 ++- .../content/file/FileDetailsViewModel.kt | 136 ++++++++------- .../content/page/PageDetailsRepository.kt | 61 ------- .../content/page/PageDetailsViewModel.kt | 54 ++++-- .../ModuleItemSequenceRepositoryTest.kt | 59 +++---- .../ModuleItemSequenceViewModelTest.kt | 12 +- .../assessment/AssessmentRepositoryTest.kt | 158 ------------------ .../content/page/PageDetailsViewModelTest.kt | 112 +++++++------ 36 files changed, 1124 insertions(+), 566 deletions(-) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentDetailsLocalDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentDetailsNetworkDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/FileContentLocalDataSource.kt rename libs/horizon/src/main/java/com/instructure/horizon/{features/moduleitemsequence/content/file/FileDetailsRepository.kt => data/datasource/FileContentNetworkDataSource.kt} (63%) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/PageLocalDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/PageNetworkDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/AssignmentDetailsRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/FileContentRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/PageRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonAssignmentDetailsDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonPageDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonAssignmentDetailsEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonPageEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetAssignmentDetailsUseCase.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetFileDetailsUseCase.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModulesWithItemsUseCase.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetPageDetailsUseCase.kt delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentRepository.kt delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepository.kt delete mode 100644 libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsRepository.kt delete mode 100644 libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentRepositoryTest.kt diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentDetailsLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentDetailsLocalDataSource.kt new file mode 100644 index 0000000000..e91e9f232f --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentDetailsLocalDataSource.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.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) }, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentDetailsNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentDetailsNetworkDataSource.kt new file mode 100644 index 0000000000..827011ade1 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentDetailsNetworkDataSource.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.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 + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt index 43810a416d..eb1e23bd9e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt @@ -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 @@ -28,7 +29,7 @@ class CourseProgressNetworkDataSource @Inject constructor( suspend fun getModuleItems(courseId: Long, forceRefresh: Boolean): List { val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceRefresh) - return moduleApi.getFirstPageModulesWithItems( + val modules = moduleApi.getFirstPageModulesWithItems( CanvasContext.Type.COURSE.apiString, courseId, params, @@ -36,5 +37,24 @@ class CourseProgressNetworkDataSource @Inject constructor( ) .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 { + val params = RestParams() + return moduleApi.getFirstPageModuleItems( + CanvasContext.Type.COURSE.apiString, + courseId, + moduleId, + params, + includes = listOf("estimated_durations") + ).depaginate { moduleApi.getNextPageModuleItemList(it, params) }.dataOrThrow } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/FileContentLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/FileContentLocalDataSource.kt new file mode 100644 index 0000000000..8692a9b5c4 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/FileContentLocalDataSource.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.models.FileFolder +import com.instructure.horizon.database.dao.HorizonFileFolderDao +import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.horizon.database.entity.HorizonFileFolderEntity +import javax.inject.Inject + +class FileContentLocalDataSource @Inject constructor( + private val localFileDao: HorizonLocalFileDao, + private val fileFolderDao: HorizonFileFolderDao, +) { + + suspend fun getLocalFilePath(fileId: Long): String? { + return localFileDao.findById(fileId)?.path + } + + suspend fun getFileFolder(fileId: Long): FileFolder? { + return fileFolderDao.findById(fileId)?.toFileFolder() + } + + suspend fun saveFileFolder(fileFolder: FileFolder) { + fileFolderDao.insert( + HorizonFileFolderEntity( + id = fileFolder.id, + url = fileFolder.url.orEmpty(), + displayName = fileFolder.displayName.orEmpty(), + contentType = fileFolder.contentType, + thumbnailUrl = fileFolder.thumbnailUrl, + ) + ) + } + + private fun HorizonFileFolderEntity.toFileFolder(): FileFolder { + return FileFolder( + id = id, + url = url, + displayName = displayName, + contentType = contentType, + thumbnailUrl = thumbnailUrl, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/file/FileDetailsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/FileContentNetworkDataSource.kt similarity index 63% rename from libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/file/FileDetailsRepository.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/datasource/FileContentNetworkDataSource.kt index d3e663f4d4..3dfa75546f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/file/FileDetailsRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/FileContentNetworkDataSource.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 - present Instructure, Inc. + * Copyright (C) 2026 - present Instructure, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,23 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.features.moduleitemsequence.content.file +package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.apis.FileFolderAPI -import com.instructure.canvasapi2.apis.OAuthAPI import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.FileFolder +import com.instructure.horizon.data.repository.HorizonFileSyncRepository import javax.inject.Inject -class FileDetailsRepository @Inject constructor( +class FileContentNetworkDataSource @Inject constructor( private val fileFolderApi: FileFolderAPI.FilesFoldersInterface, - private val oAuthApi: OAuthAPI.OAuthInterface + private val fileSyncRepository: HorizonFileSyncRepository, ) { - suspend fun getFileFolderFromURL(url: String): FileFolder? { + + suspend fun getFileDetails(url: String): FileFolder? { return fileFolderApi.getFileFolderFromURL(url, RestParams()).dataOrNull } - suspend fun getAuthenticatedFileUrl(fileUrl: String): String { - return oAuthApi.getAuthenticatedSession("$fileUrl?display=borderless", RestParams(isForceReadFromNetwork = true)).dataOrThrow.sessionUrl + suspend fun downloadFile(fileId: Long, courseId: Long) { + fileSyncRepository.downloadFile(fileId, courseId) } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/PageLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/PageLocalDataSource.kt new file mode 100644 index 0000000000..e200fa1117 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/PageLocalDataSource.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.datasource + +import com.instructure.canvasapi2.models.Page +import com.instructure.horizon.database.dao.HorizonPageDao +import com.instructure.horizon.database.entity.HorizonPageEntity +import javax.inject.Inject + +class PageLocalDataSource @Inject constructor( + private val pageDao: HorizonPageDao, +) { + + suspend fun getPage(courseId: Long, pageUrl: String): Page? { + return pageDao.getPage(courseId, pageUrl)?.toPage() + } + + suspend fun savePage(page: Page, courseId: Long, parsedBody: String?) { + pageDao.savePage( + HorizonPageEntity( + pageId = page.id, + courseId = courseId, + pageUrl = page.url.orEmpty(), + title = page.title, + body = parsedBody, + ) + ) + } + + private fun HorizonPageEntity.toPage(): Page { + return Page( + id = pageId, + url = pageUrl, + title = title, + body = body, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/PageNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/PageNetworkDataSource.kt new file mode 100644 index 0000000000..8f7bf96e83 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/PageNetworkDataSource.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.apis.PageAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.Page +import javax.inject.Inject + +class PageNetworkDataSource @Inject constructor( + private val pageApi: PageAPI.PagesInterface, +) { + + suspend fun getPage(courseId: Long, pageUrl: String, forceRefresh: Boolean): Page { + val params = RestParams(isForceReadFromNetwork = forceRefresh) + return pageApi.getDetailedPage(CanvasContext.Type.COURSE.apiString, courseId, pageUrl, params).dataOrThrow + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/AssignmentDetailsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/AssignmentDetailsRepository.kt new file mode 100644 index 0000000000..ca3ee76b66 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/AssignmentDetailsRepository.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.models.Assignment +import com.instructure.horizon.data.datasource.AssignmentDetailsLocalDataSource +import com.instructure.horizon.data.datasource.AssignmentDetailsNetworkDataSource +import com.instructure.horizon.di.HorizonHtmlParserQualifier +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.features.offline.sync.HtmlParser +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class AssignmentDetailsRepository @Inject constructor( + private val networkDataSource: AssignmentDetailsNetworkDataSource, + private val localDataSource: AssignmentDetailsLocalDataSource, + @HorizonHtmlParserQualifier private val htmlParser: HtmlParser, + private val fileSyncRepository: HorizonFileSyncRepository, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getAssignment(courseId: Long, assignmentId: Long, forceRefresh: Boolean): Assignment { + return if (shouldFetchFromNetwork()) { + networkDataSource.getAssignment(courseId, assignmentId, forceRefresh).also { assignment -> + if (shouldSync()) { + val parsingResult = htmlParser.createHtmlStringWithLocalFiles(assignment.description, courseId) + localDataSource.saveAssignment(assignment, courseId, parsingResult.htmlWithLocalFileLinks) + fileSyncRepository.syncHtmlFiles(courseId, parsingResult) + } + } + } else { + localDataSource.getAssignment(assignmentId) + ?: throw IllegalStateException("Assignment $assignmentId not available offline") + } + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/FileContentRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/FileContentRepository.kt new file mode 100644 index 0000000000..efe3dd9c3a --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/FileContentRepository.kt @@ -0,0 +1,82 @@ +/* + * 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.FileContentLocalDataSource +import com.instructure.horizon.data.datasource.FileContentNetworkDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +data class FileDetails( + val id: Long, + val url: String, + val displayName: String, + val contentType: String?, + val thumbnailUrl: String?, + val localPath: String?, +) + +class FileContentRepository @Inject constructor( + private val networkDataSource: FileContentNetworkDataSource, + private val localDataSource: FileContentLocalDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getFileDetails(url: String, courseId: Long): FileDetails { + return if (shouldFetchFromNetwork()) { + val fileFolder = networkDataSource.getFileDetails(url) + ?: throw IllegalStateException("File not found: $url") + if (shouldSync()) { + localDataSource.saveFileFolder(fileFolder) + networkDataSource.downloadFile(fileFolder.id, courseId) + } + val localPath = localDataSource.getLocalFilePath(fileFolder.id) + FileDetails( + id = fileFolder.id, + url = fileFolder.url.orEmpty(), + displayName = fileFolder.displayName.orEmpty(), + contentType = fileFolder.contentType, + thumbnailUrl = fileFolder.thumbnailUrl, + localPath = localPath, + ) + } else { + val fileId = extractFileId(url) + ?: throw IllegalStateException("Cannot determine file ID from URL: $url") + val fileFolder = localDataSource.getFileFolder(fileId) + ?: throw IllegalStateException("File $fileId not available offline") + val localPath = localDataSource.getLocalFilePath(fileId) + FileDetails( + id = fileFolder.id, + url = fileFolder.url.orEmpty(), + displayName = fileFolder.displayName.orEmpty(), + contentType = fileFolder.contentType, + thumbnailUrl = fileFolder.thumbnailUrl, + localPath = localPath, + ) + } + } + + private fun extractFileId(url: String): Long? { + return Regex("files/(\\d+)").find(url)?.groupValues?.get(1)?.toLongOrNull() + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt index 5a702c1306..10ab43df6c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt @@ -37,6 +37,10 @@ class HorizonFileSyncRepository @Inject constructor( featureFlagProvider: FeatureFlagProvider, ) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + suspend fun downloadFile(fileId: Long, courseId: Long) { + downloadInternalFile(fileId, courseId) + } + suspend fun syncHtmlFiles(courseId: Long, parsingResult: HtmlParsingResult) = withContext(Dispatchers.IO) { val alreadyDownloadedIds = localFileDao.findByCourseId(courseId).map { it.id }.toSet() val internalFileIdsToSync = parsingResult.internalFileIds.filterNot { alreadyDownloadedIds.contains(it) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/PageRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/PageRepository.kt new file mode 100644 index 0000000000..cb27687caa --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/PageRepository.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.models.Page +import com.instructure.horizon.data.datasource.PageLocalDataSource +import com.instructure.horizon.data.datasource.PageNetworkDataSource +import com.instructure.horizon.di.HorizonHtmlParserQualifier +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.features.offline.sync.HtmlParser +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class PageRepository @Inject constructor( + private val networkDataSource: PageNetworkDataSource, + private val localDataSource: PageLocalDataSource, + @HorizonHtmlParserQualifier private val htmlParser: HtmlParser, + private val fileSyncRepository: HorizonFileSyncRepository, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getPage(courseId: Long, pageUrl: String, forceRefresh: Boolean): Page { + return if (shouldFetchFromNetwork()) { + networkDataSource.getPage(courseId, pageUrl, forceRefresh).also { page -> + if (shouldSync()) { + val parsingResult = htmlParser.createHtmlStringWithLocalFiles(page.body, courseId) + localDataSource.savePage(page, courseId, parsingResult.htmlWithLocalFileLinks) + fileSyncRepository.syncHtmlFiles(courseId, parsingResult) + } + } + } else { + localDataSource.getPage(courseId, pageUrl) + ?: throw IllegalStateException("Page '$pageUrl' not available offline") + } + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt index 715a2523f9..8565698f4c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt @@ -18,6 +18,7 @@ package com.instructure.horizon.database import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +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 @@ -28,8 +29,10 @@ 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.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonAssignmentDetailsEntity import com.instructure.horizon.database.entity.HorizonCourseAssignmentEntity import com.instructure.horizon.database.entity.HorizonCourseAssignmentGroupEntity import com.instructure.horizon.database.entity.HorizonCourseEntity @@ -44,6 +47,7 @@ import com.instructure.horizon.database.entity.HorizonLearnCollectionItemEntity import com.instructure.horizon.database.entity.HorizonLearnItemEntity import com.instructure.horizon.database.entity.HorizonLearnSavedItemEntity import com.instructure.horizon.database.entity.HorizonLocalFileEntity +import com.instructure.horizon.database.entity.HorizonPageEntity import com.instructure.horizon.database.entity.HorizonProgramCourseRef import com.instructure.horizon.database.entity.HorizonProgramEntity import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity @@ -68,8 +72,10 @@ import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity HorizonCourseGradeEntity::class, HorizonLocalFileEntity::class, HorizonFileFolderEntity::class, + HorizonPageEntity::class, + HorizonAssignmentDetailsEntity::class, ], - version = 7, + version = 8, ) abstract class HorizonDatabase : RoomDatabase() { abstract fun dashboardEnrollmentDao(): HorizonDashboardEnrollmentDao @@ -84,4 +90,6 @@ abstract class HorizonDatabase : RoomDatabase() { abstract fun courseScoreDao(): HorizonCourseScoreDao abstract fun localFileDao(): HorizonLocalFileDao abstract fun fileFolderDao(): HorizonFileFolderDao + abstract fun pageDao(): HorizonPageDao + abstract fun assignmentDetailsDao(): HorizonAssignmentDetailsDao } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonAssignmentDetailsDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonAssignmentDetailsDao.kt new file mode 100644 index 0000000000..e9104c2894 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonAssignmentDetailsDao.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.horizon.database.entity.HorizonAssignmentDetailsEntity + +@Dao +interface HorizonAssignmentDetailsDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun saveAssignment(assignment: HorizonAssignmentDetailsEntity) + + @Query("SELECT * FROM horizon_assignment_details WHERE assignmentId = :assignmentId LIMIT 1") + suspend fun getAssignment(assignmentId: Long): HorizonAssignmentDetailsEntity? +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt index c97bcd9337..1e57d5e406 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt @@ -38,6 +38,12 @@ interface HorizonCourseModuleDao { @Query("SELECT * FROM horizon_course_module_items WHERE moduleId = :moduleId ORDER BY position") suspend fun getItemsForModule(moduleId: Long): List + @Query("SELECT * FROM horizon_course_module_items WHERE courseId = :courseId ORDER BY moduleId, position") + suspend fun getItemsForCourse(courseId: Long): List + + @Query("SELECT * FROM horizon_course_module_items WHERE itemId = :itemId LIMIT 1") + suspend fun getItemById(itemId: Long): HorizonCourseModuleItemEntity? + @Query("DELETE FROM horizon_course_modules WHERE courseId = :courseId") suspend fun deleteModulesForCourse(courseId: Long) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonPageDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonPageDao.kt new file mode 100644 index 0000000000..f532a3a47e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonPageDao.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.horizon.database.entity.HorizonPageEntity + +@Dao +interface HorizonPageDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun savePage(page: HorizonPageEntity) + + @Query("SELECT * FROM horizon_pages WHERE courseId = :courseId AND pageUrl = :pageUrl LIMIT 1") + suspend fun getPage(courseId: Long, pageUrl: String): HorizonPageEntity? +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonAssignmentDetailsEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonAssignmentDetailsEntity.kt new file mode 100644 index 0000000000..c5f23a63b0 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonAssignmentDetailsEntity.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.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Stores assignment/quiz content for offline access. + * [description] contains the parsed HTML with local file references replacing remote URLs. + * [submissionTypes] is a comma-separated list of submission type API strings. + * [url] is the assignment URL (used by quizzes/assessments to launch the quiz). + * [ltiToolUrl] is from [com.instructure.canvasapi2.models.ExternalToolAttributes.url], used for LTI button. + * Submission history is not stored; offline view shows description only. + */ +@Entity( + tableName = "horizon_assignment_details", + indices = [Index("courseId")] +) +data class HorizonAssignmentDetailsEntity( + @PrimaryKey val assignmentId: Long, + val courseId: Long, + val name: String?, + val description: String?, + val pointsPossible: Double, + val allowedAttempts: Long, + val dueAt: String?, + val submissionTypes: String, + val gradingType: String?, + val lockedForUser: Boolean, + val lockExplanation: String?, + val quizId: Long, + val url: String?, + val ltiToolUrl: String?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt index 674e199da9..cbe77153f7 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt @@ -24,4 +24,6 @@ data class HorizonFileFolderEntity( val id: Long, val url: String, val displayName: String, + val contentType: String? = null, + val thumbnailUrl: String? = null, ) \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonPageEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonPageEntity.kt new file mode 100644 index 0000000000..667781c384 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonPageEntity.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.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Stores page content for offline access. + * [body] contains the parsed HTML with local file references replacing remote URLs. + * [pageUrl] is the slug used by the Pages API (e.g. "introduction-to-kotlin"). + */ +@Entity( + tableName = "horizon_pages", + indices = [Index("courseId"), Index("pageUrl")] +) +data class HorizonPageEntity( + @PrimaryKey val pageId: Long, + val courseId: Long, + val pageUrl: String, + val title: String?, + val body: String?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt index 5d12b56932..489a5c298e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt @@ -22,6 +22,7 @@ import com.instructure.horizon.database.HorizonDatabaseProvider import com.instructure.horizon.offline.HorizonHtmlParserFileSource import com.instructure.pandautils.features.offline.sync.HtmlParser import dagger.hilt.android.qualifiers.ApplicationContext +import com.instructure.horizon.database.dao.HorizonAssignmentDetailsDao import com.instructure.horizon.database.dao.HorizonCourseDao import com.instructure.horizon.database.dao.HorizonCourseModuleDao import com.instructure.horizon.database.dao.HorizonCourseScoreDao @@ -32,6 +33,7 @@ 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.HorizonSyncMetadataDao import dagger.Module @@ -111,6 +113,16 @@ class HorizonOfflineModule { return db.fileFolderDao() } + @Provides + fun provideHorizonPageDao(db: HorizonDatabase): HorizonPageDao { + return db.pageDao() + } + + @Provides + fun provideHorizonAssignmentDetailsDao(db: HorizonDatabase): HorizonAssignmentDetailsDao { + return db.assignmentDetailsDao() + } + @Provides @HorizonHtmlParserQualifier fun provideHorizonHtmlParser( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetAssignmentDetailsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetAssignmentDetailsUseCase.kt new file mode 100644 index 0000000000..8791faec08 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetAssignmentDetailsUseCase.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.Assignment +import com.instructure.horizon.data.repository.AssignmentDetailsRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetAssignmentDetailsUseCase @Inject constructor( + private val repository: AssignmentDetailsRepository, +) : BaseUseCase() { + + data class Params( + val courseId: Long, + val assignmentId: Long, + val forceRefresh: Boolean = false, + ) + + override suspend fun execute(params: Params): Assignment { + return repository.getAssignment(params.courseId, params.assignmentId, params.forceRefresh) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetFileDetailsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetFileDetailsUseCase.kt new file mode 100644 index 0000000000..6001f483ee --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetFileDetailsUseCase.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.horizon.data.repository.FileContentRepository +import com.instructure.horizon.data.repository.FileDetails +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetFileDetailsUseCase @Inject constructor( + private val repository: FileContentRepository, +) : BaseUseCase() { + + data class Params( + val url: String, + val courseId: Long, + ) + + override suspend fun execute(params: Params): FileDetails { + return repository.getFileDetails(params.url, params.courseId) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModulesWithItemsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModulesWithItemsUseCase.kt new file mode 100644 index 0000000000..4b36d9a33f --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModulesWithItemsUseCase.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.data.repository.CourseRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetModulesWithItemsUseCase @Inject constructor( + private val repository: CourseRepository, +) : BaseUseCase>() { + + data class Params( + val courseId: Long, + val forceRefresh: Boolean = false, + ) + + override suspend fun execute(params: Params): List { + return repository.getModuleItems(params.courseId, params.forceRefresh) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetPageDetailsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetPageDetailsUseCase.kt new file mode 100644 index 0000000000..f41c78ae0d --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetPageDetailsUseCase.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.Page +import com.instructure.horizon.data.repository.PageRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetPageDetailsUseCase @Inject constructor( + private val repository: PageRepository, +) : BaseUseCase() { + + data class Params( + val courseId: Long, + val pageUrl: String, + val forceRefresh: Boolean = false, + ) + + override suspend fun execute(params: Params): Page { + return repository.getPage(params.courseId, params.pageUrl, params.forceRefresh) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt index 0423c2d35e..09cc43324b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt @@ -15,82 +15,86 @@ */ package com.instructure.horizon.features.moduleitemsequence -import com.instructure.canvasapi2.apis.AssignmentAPI import com.instructure.canvasapi2.apis.ModuleAPI import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager -import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.ModuleCompletionRequirement +import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleItemSequence +import com.instructure.canvasapi2.models.ModuleItemWrapper import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult -import com.instructure.canvasapi2.utils.depaginate +import com.instructure.horizon.data.repository.CourseRepository +import com.instructure.horizon.database.dao.HorizonCourseModuleDao +import com.instructure.horizon.database.entity.HorizonCourseModuleItemEntity +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.orDefault import okhttp3.ResponseBody import javax.inject.Inject class ModuleItemSequenceRepository @Inject constructor( private val moduleApi: ModuleAPI.ModuleInterface, - private val assignmentApi: AssignmentAPI.AssignmentInterface, + private val courseRepository: CourseRepository, + private val courseModuleDao: HorizonCourseModuleDao, private val horizonGetCommentsManager: HorizonGetCommentsManager, - private val apiPrefs: ApiPrefs -) { + private val apiPrefs: ApiPrefs, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { suspend fun getModuleItemSequence(courseId: Long, assetType: String, assetId: String): ModuleItemSequence { - val params = RestParams(isForceReadFromNetwork = true) - return moduleApi.getModuleItemSequence( - CanvasContext.Type.COURSE.apiString, - courseId, - assetType, - assetId, - params - ).dataOrThrow + return if (shouldFetchFromNetwork()) { + val params = RestParams(isForceReadFromNetwork = true) + moduleApi.getModuleItemSequence( + CanvasContext.Type.COURSE.apiString, + courseId, + assetType, + assetId, + params + ).dataOrThrow + } else { + val itemId = findItemIdLocally(courseId, assetType, assetId) + ModuleItemSequence( + items = arrayOf(ModuleItemWrapper(current = ModuleItem(id = itemId))) + ) + } } - suspend fun getModulesWithItems(courseId: Long, forceNetwork: Boolean): List { - val params = RestParams(isForceReadFromNetwork = forceNetwork) - val modules = moduleApi.getFirstPageModulesWithItems( - CanvasContext.Type.COURSE.apiString, - courseId, - params, - includes = listOf("estimated_durations") - ).depaginate { - moduleApi.getNextPageModuleObjectList(it, params) - }.dataOrThrow - - return modules.map { - if (it.itemCount != it.items.size) { - it.copy(items = getAllModuleItems(courseId, it.id)) - } else { - it + private suspend fun findItemIdLocally(courseId: Long, assetType: String, assetId: String): Long { + val items = courseModuleDao.getItemsForCourse(courseId) + return items.find { item -> + when (assetType) { + "Assignment", "Quiz" -> item.url?.endsWith("/$assetId") == true + "Page" -> item.url?.contains("/pages/$assetId") == true + "File" -> item.url?.endsWith("/$assetId") == true + else -> false } - } + }?.itemId ?: -1L } - private suspend fun getAllModuleItems(courseId: Long, moduleId: Long): List { - val params = RestParams() - return moduleApi.getFirstPageModuleItems( - CanvasContext.Type.COURSE.apiString, - courseId, - moduleId, - params, - includes = listOf("estimated_durations") - ).depaginate { - moduleApi.getNextPageModuleItemList(it, params) - }.dataOrThrow + suspend fun getModulesWithItems(courseId: Long, forceNetwork: Boolean): List { + return courseRepository.getModuleItems(courseId, forceNetwork) } suspend fun getModuleItem(courseId: Long, moduleId: Long, moduleItemId: Long): ModuleItem { - val params = RestParams(isForceReadFromNetwork = true) - return moduleApi.getModuleItem( - CanvasContext.Type.COURSE.apiString, - courseId, - moduleId, - moduleItemId, - params - ).dataOrThrow + return if (shouldFetchFromNetwork()) { + val params = RestParams(isForceReadFromNetwork = true) + moduleApi.getModuleItem( + CanvasContext.Type.COURSE.apiString, + courseId, + moduleId, + moduleItemId, + params + ).dataOrThrow + } else { + courseModuleDao.getItemById(moduleItemId)?.toModuleItem() + ?: throw IllegalStateException("Module item $moduleItemId not available offline") + } } suspend fun markAsNotDone(courseId: Long, moduleItem: ModuleItem): DataResult { @@ -118,16 +122,45 @@ class ModuleItemSequenceRepository @Inject constructor( moduleApi.markModuleItemRead(CanvasContext.Type.COURSE.apiString, courseId, moduleId, itemId, restParams) } - suspend fun getAssignment(assignmentId: Long, courseId: Long, forceNetwork: Boolean): Assignment { - val params = RestParams(isForceReadFromNetwork = forceNetwork) - return assignmentApi.getAssignmentWithHistory(courseId, assignmentId, params).dataOrThrow - } - - suspend fun hasUnreadComments( - assignmentId: Long?, - forceNetwork: Boolean = false - ): Boolean { + suspend fun hasUnreadComments(assignmentId: Long?, forceNetwork: Boolean = false): Boolean { if (assignmentId == null) return false return horizonGetCommentsManager.getUnreadCommentsCount(assignmentId, apiPrefs.user?.id.orDefault(), forceNetwork) > 0 } -} \ No newline at end of file + + private fun HorizonCourseModuleItemEntity.toModuleItem(): ModuleItem { + val completionRequirement = completionRequirementType?.let { + ModuleCompletionRequirement( + type = it, + minScore = completionRequirementMinScore, + completed = completionRequirementCompleted, + ) + } + val moduleDetails = if (pointsPossible != null || dueAt != null || lockedForUser || lockExplanation != null) { + ModuleContentDetails( + pointsPossible = pointsPossible, + dueAt = dueAt, + lockedForUser = lockedForUser, + lockExplanation = lockExplanation, + lockAt = lockAt, + unlockAt = unlockAt, + ) + } else null + return ModuleItem( + id = itemId, + moduleId = moduleId, + position = position, + title = title, + type = type, + htmlUrl = htmlUrl, + url = url, + completionRequirement = completionRequirement, + moduleDetails = moduleDetails, + quizLti = quizLti, + estimatedDuration = estimatedDuration, + ) + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt index 12a6b833b0..4daee9e2d3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt @@ -28,6 +28,7 @@ import com.instructure.canvasapi2.utils.isLocked import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetAssignmentDetailsUseCase import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider import com.instructure.horizon.features.aiassistant.common.model.AiAssistContext import com.instructure.horizon.features.aiassistant.common.model.AiAssistContextSource @@ -56,6 +57,7 @@ import javax.inject.Inject class ModuleItemSequenceViewModel @Inject constructor( @ApplicationContext private val context: Context, private val repository: ModuleItemSequenceRepository, + private val getAssignmentDetailsUseCase: GetAssignmentDetailsUseCase, private val moduleItemCardStateMapper: ModuleItemCardStateMapper, private val aiAssistContextProvider: AiAssistContextProvider, savedStateHandle: SavedStateHandle, @@ -522,7 +524,7 @@ class ModuleItemSequenceViewModel @Inject constructor( private suspend fun getAssignment(item: ModuleItem?, forceNetwork: Boolean): Assignment? { if (item?.type != Type.Assignment.name) return null - return repository.getAssignment(item.contentId, courseId, forceNetwork = forceNetwork) + return getAssignmentDetailsUseCase(GetAssignmentDetailsUseCase.Params(courseId, item.contentId, forceNetwork)) } private fun getAttemptCount(assignment: Assignment?): String? { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentRepository.kt deleted file mode 100644 index 46e7717bb5..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentRepository.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (C) 2025 - 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.moduleitemsequence.content.assessment - -import com.instructure.canvasapi2.apis.AssignmentAPI -import com.instructure.canvasapi2.apis.LaunchDefinitionsAPI -import com.instructure.canvasapi2.apis.OAuthAPI -import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.models.Assignment -import javax.inject.Inject - -class AssessmentRepository @Inject constructor( - private val assignmentApi: AssignmentAPI.AssignmentInterface, - private val oAuthInterface: OAuthAPI.OAuthInterface, - private val launchDefinitionsApi: LaunchDefinitionsAPI.LaunchDefinitionsInterface, -) { - - suspend fun getAssignment(assignmentId: Long, courseId: Long, forceNetwork: Boolean): Assignment { - val params = RestParams(isForceReadFromNetwork = forceNetwork) - return assignmentApi.getAssignmentWithHistory(courseId, assignmentId, params).dataOrThrow - } - - suspend fun authenticateUrl(url: String): String { - val ltiTool = launchDefinitionsApi.getLtiFromAuthenticationUrl(url, RestParams(isForceReadFromNetwork = true)).dataOrThrow - return ltiTool.url?.let { - oAuthInterface.getAuthenticatedSession( - it, - RestParams(isForceReadFromNetwork = true) - ).dataOrNull?.sessionUrl ?: url - } ?: url - } -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModel.kt index eeedc0de33..168be11f8f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModel.kt @@ -18,8 +18,12 @@ package com.instructure.horizon.features.moduleitemsequence.content.assessment import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.apis.LaunchDefinitionsAPI +import com.instructure.canvasapi2.apis.OAuthAPI +import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.domain.usecase.GetAssignmentDetailsUseCase import com.instructure.horizon.features.moduleitemsequence.ModuleItemContent import com.instructure.horizon.horizonui.platform.LoadingState import com.instructure.pandautils.utils.Const @@ -33,7 +37,9 @@ import javax.inject.Inject @HiltViewModel class AssessmentViewModel @Inject constructor( - val repository: AssessmentRepository, + private val getAssignmentDetailsUseCase: GetAssignmentDetailsUseCase, + private val launchDefinitionsApi: LaunchDefinitionsAPI.LaunchDefinitionsInterface, + private val oAuthApi: OAuthAPI.OAuthInterface, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -54,7 +60,7 @@ class AssessmentViewModel @Inject constructor( private fun onAssessmentCompletion() { _uiState.update { it.copy(assessmentCompletionLoading = true) } viewModelScope.launch { - delay(15000) // This is based on the iOS app, we need to add a loading delay so the quiz result would be processed correctly + delay(15000) // Loading delay so the quiz result is processed correctly before we refresh _uiState.update { it.copy(assessmentCompletionLoading = false) } } } @@ -70,7 +76,7 @@ class AssessmentViewModel @Inject constructor( it.copy(loadingState = LoadingState(isLoading = true)) } viewModelScope.tryLaunch { - val assignment = repository.getAssignment(assignmentId, courseId, false) + val assignment = getAssignmentDetailsUseCase(GetAssignmentDetailsUseCase.Params(courseId, assignmentId)) assessmentUrl = assignment.url _uiState.update { it.copy(loadingState = LoadingState(isLoading = false), assessmentName = assignment.name.orEmpty()) @@ -86,7 +92,11 @@ class AssessmentViewModel @Inject constructor( _uiState.update { it.copy(showAssessmentDialog = true, assessmentLoading = true) } viewModelScope.tryLaunch { assessmentUrl?.let { url -> - val authenticatedUrl = repository.authenticateUrl(url) + val ltiTool = launchDefinitionsApi.getLtiFromAuthenticationUrl(url, RestParams(isForceReadFromNetwork = true)).dataOrThrow + val authenticatedUrl = ltiTool.url?.let { ltiUrl -> + oAuthApi.getAuthenticatedSession(ltiUrl, RestParams(isForceReadFromNetwork = true)) + .dataOrNull?.sessionUrl ?: url + } ?: url _uiState.update { it.copy(urlToLoad = authenticatedUrl) } } ?: run { _uiState.update { it.copy(assessmentLoading = false) } @@ -103,4 +113,4 @@ class AssessmentViewModel @Inject constructor( private fun onAssessmentLoaded() { _uiState.update { it.copy(assessmentLoading = false) } } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepository.kt deleted file mode 100644 index 21bdfdb775..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepository.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (C) 2025 - 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.moduleitemsequence.content.assignment - -import com.instructure.canvasapi2.apis.AssignmentAPI -import com.instructure.canvasapi2.apis.OAuthAPI -import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager -import com.instructure.canvasapi2.models.Assignment -import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.pandautils.utils.orDefault -import javax.inject.Inject - -class AssignmentDetailsRepository @Inject constructor( - private val assignmentApi: AssignmentAPI.AssignmentInterface, - private val oAuthInterface: OAuthAPI.OAuthInterface, - private val horizonGetCommentsManager: HorizonGetCommentsManager, - private val apiPrefs: ApiPrefs, -) { - suspend fun getAssignment(assignmentId: Long, courseId: Long, forceNetwork: Boolean): Assignment { - val params = RestParams(isForceReadFromNetwork = forceNetwork) - return assignmentApi.getAssignmentWithHistory(courseId, assignmentId, params).dataOrThrow - } - - suspend fun authenticateUrl(url: String): String { - return oAuthInterface.getAuthenticatedSession( - url, - RestParams(isForceReadFromNetwork = true) - ).dataOrNull?.sessionUrl ?: url - } - - suspend fun hasUnreadComments( - assignmentId: Long, - forceNetwork: Boolean = false - ): Boolean { - return horizonGetCommentsManager.getUnreadCommentsCount(assignmentId, apiPrefs.user?.id.orDefault(), forceNetwork) > 0 - } -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModel.kt index 36aa4729dc..a078b768cf 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModel.kt @@ -19,11 +19,16 @@ import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.apis.OAuthAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Submission +import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetAssignmentDetailsUseCase import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider import com.instructure.horizon.features.aiassistant.common.model.AiAssistContext import com.instructure.horizon.features.aiassistant.common.model.AiAssistContextSource @@ -32,6 +37,7 @@ import com.instructure.horizon.horizonui.organisms.cards.AttemptCardState import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.HtmlContentFormatter import com.instructure.pandautils.utils.localisedFormat +import com.instructure.pandautils.utils.orDefault import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow @@ -44,8 +50,11 @@ import javax.inject.Inject @HiltViewModel class AssignmentDetailsViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val assignmentDetailsRepository: AssignmentDetailsRepository, + private val getAssignmentDetailsUseCase: GetAssignmentDetailsUseCase, private val htmlContentFormatter: HtmlContentFormatter, + private val oAuthApi: OAuthAPI.OAuthInterface, + private val horizonGetCommentsManager: HorizonGetCommentsManager, + private val apiPrefs: ApiPrefs, private val aiAssistContextProvider: AiAssistContextProvider, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -83,7 +92,7 @@ class AssignmentDetailsViewModel @Inject constructor( private fun loadData() { _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = true)) } viewModelScope.tryLaunch { - val assignment = assignmentDetailsRepository.getAssignment(assignmentId, courseId, forceNetwork = false) + val assignment = getAssignmentDetailsUseCase(GetAssignmentDetailsUseCase.Params(courseId, assignmentId, forceRefresh = false)) _assignmentFlow.value = assignment val lastActualSubmission = assignment.lastGradedOrSubmittedSubmission val attempts = assignment.submission?.submissionHistory?.filterNotNull() ?: emptyList() @@ -98,7 +107,7 @@ class AssignmentDetailsViewModel @Inject constructor( val attemptsUiState = createAttemptCardsState(attempts, assignment, initialAttempt) val showAttemptSelector = assignment.allowedAttempts != 1L - val hasUnreadComments = assignmentDetailsRepository.hasUnreadComments(assignmentId) + val hasUnreadComments = horizonGetCommentsManager.getUnreadCommentsCount(assignmentId, apiPrefs.user?.id.orDefault(), false) > 0 aiAssistContextProvider.aiAssistContext = AiAssistContext( contextString = assignment.description.orEmpty(), @@ -215,9 +224,10 @@ class AssignmentDetailsViewModel @Inject constructor( private fun ltiButtonPressed(ltiUrl: String) { viewModelScope.launch { try { - val authenticatedSessionURL = - assignmentDetailsRepository.authenticateUrl(ltiUrl) - + val authenticatedSessionURL = oAuthApi.getAuthenticatedSession( + ltiUrl, + RestParams(isForceReadFromNetwork = true) + ).dataOrNull?.sessionUrl ?: ltiUrl _uiState.update { it.copy(urlToOpen = authenticatedSessionURL) } } catch (e: Exception) { _uiState.update { it.copy(urlToOpen = ltiUrl) } @@ -242,7 +252,7 @@ class AssignmentDetailsViewModel @Inject constructor( } private suspend fun updateAssignment() { - val assignment = assignmentDetailsRepository.getAssignment(assignmentId, courseId, forceNetwork = true) + val assignment = getAssignmentDetailsUseCase(GetAssignmentDetailsUseCase.Params(courseId, assignmentId, forceRefresh = true)) _assignmentFlow.value = assignment val lastActualSubmission = assignment.lastGradedOrSubmittedSubmission val attempts = assignment.submission?.submissionHistory?.filterNotNull() ?: emptyList() diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/file/FileDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/file/FileDetailsViewModel.kt index 6a6ce74138..1a71694d88 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/file/FileDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/file/FileDetailsViewModel.kt @@ -21,15 +21,18 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.WorkManager import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.instructure.canvasapi2.models.FileFolder +import com.instructure.canvasapi2.apis.OAuthAPI +import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.domain.usecase.GetFileDetailsUseCase import com.instructure.horizon.features.account.filepreview.FilePreviewUiState import com.instructure.horizon.features.moduleitemsequence.ModuleItemContent import com.instructure.pandautils.features.file.download.FileDownloadWorker import com.instructure.pandautils.room.appdatabase.daos.FileDownloadProgressDao import com.instructure.pandautils.room.appdatabase.entities.FileDownloadProgressEntity import com.instructure.pandautils.room.appdatabase.entities.FileDownloadProgressState +import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.filecache.FileCache import com.instructure.pandautils.utils.filecache.awaitFileDownload import dagger.hilt.android.lifecycle.HiltViewModel @@ -43,7 +46,8 @@ import javax.inject.Inject @HiltViewModel class FileDetailsViewModel @Inject constructor( - private val fileDetailsRepository: FileDetailsRepository, + private val getFileDetailsUseCase: GetFileDetailsUseCase, + private val oAuthApi: OAuthAPI.OAuthInterface, private val workManager: WorkManager, private val fileDownloadProgressDao: FileDownloadProgressDao, private val fileCache: FileCache, @@ -52,6 +56,7 @@ class FileDetailsViewModel @Inject constructor( ) : ViewModel() { private val fileUrl = savedStateHandle[ModuleItemContent.File.FILE_URL] ?: "" + private val courseId: Long = savedStateHandle[Const.COURSE_ID] ?: -1L private val _uiState = MutableStateFlow( @@ -67,7 +72,8 @@ class FileDetailsViewModel @Inject constructor( private var runningWorkerId: UUID? = null - private var fileFolder: FileFolder? = null + private var fileUrl_: String = "" + private var displayName_: String = "" init { loadData() @@ -76,18 +82,32 @@ class FileDetailsViewModel @Inject constructor( private fun loadData() { _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = true)) } viewModelScope.tryLaunch { - fileFolder = fileDetailsRepository.getFileFolderFromURL(fileUrl) - val authUrl = fileDetailsRepository.getAuthenticatedFileUrl(fileUrl.replace("api/v1/", "")) - fileFolder?.let { file -> - _uiState.update { - it.copy( - fileId = file.id, - loadingState = it.loadingState.copy(isLoading = false), - fileName = file.displayName.orEmpty(), - filePreview = getFilePreview(file, authUrl), - mimeType = file.contentType ?: "*/*", - ) - } + val fileDetails = getFileDetailsUseCase(GetFileDetailsUseCase.Params(fileUrl, courseId)) + fileUrl_ = fileDetails.url + displayName_ = fileDetails.displayName + val authUrl = if (fileDetails.localPath == null) { + oAuthApi.getAuthenticatedSession( + "${fileDetails.url.replace("api/v1/", "")}?display=borderless", + RestParams(isForceReadFromNetwork = true) + ).dataOrNull?.sessionUrl + } else { + null + } + _uiState.update { + it.copy( + fileId = fileDetails.id, + loadingState = it.loadingState.copy(isLoading = false), + fileName = fileDetails.displayName, + filePreview = getFilePreview( + url = fileDetails.url, + displayName = fileDetails.displayName, + contentType = fileDetails.contentType.orEmpty(), + thumbnailUrl = fileDetails.thumbnailUrl.orEmpty(), + authUrl = authUrl, + localPath = fileDetails.localPath, + ), + mimeType = fileDetails.contentType ?: "*/*", + ) } } catch { _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = false, isError = true)) } @@ -95,21 +115,19 @@ class FileDetailsViewModel @Inject constructor( } private fun onDownloadClicked() { - fileFolder?.let { file -> - _uiState.update { it.copy(downloadState = FileDownloadProgressState.STARTING) } - val workRequest = FileDownloadWorker.createOneTimeWorkRequest(file.displayName.orEmpty(), file.url.orEmpty()) - workManager.enqueue(workRequest) - runningWorkerId = workRequest.id - val workerId = workRequest.id.toString() - viewModelScope.tryLaunch { - fileDownloadProgressDao.findByWorkerIdFlow(workerId) - .collect { progress -> - updateProgress(progress) - } - } catch { - _uiState.update { - it.copy(downloadState = FileDownloadProgressState.ERROR) + _uiState.update { it.copy(downloadState = FileDownloadProgressState.STARTING) } + val workRequest = FileDownloadWorker.createOneTimeWorkRequest(displayName_, fileUrl_) + workManager.enqueue(workRequest) + runningWorkerId = workRequest.id + val workerId = workRequest.id.toString() + viewModelScope.tryLaunch { + fileDownloadProgressDao.findByWorkerIdFlow(workerId) + .collect { progress -> + updateProgress(progress) } + } catch { + _uiState.update { + it.copy(downloadState = FileDownloadProgressState.ERROR) } } } @@ -137,44 +155,48 @@ class FileDetailsViewModel @Inject constructor( _uiState.update { it.copy(filePathToOpen = null) } } - private suspend fun getFilePreview(file: FileFolder, authUrl: String): FilePreviewUiState { - val url = file.url.orEmpty() - val displayName = file.displayName.orEmpty() - val contentType = file.contentType.orEmpty() - val thumbnailUrl = file.thumbnailUrl.orEmpty() - + private suspend fun getFilePreview( + url: String, + displayName: String, + contentType: String, + thumbnailUrl: String, + authUrl: String?, + localPath: String?, + ): FilePreviewUiState { try { return when { contentType == "application/pdf" -> { - val tempFile: File? = fileCache.awaitFileDownload(url) - tempFile?.let { - FilePreviewUiState.Pdf(Uri.fromFile(it)) - } ?: FilePreviewUiState.NoPreview + val uri = if (localPath != null) { + Uri.fromFile(File(localPath)) + } else { + Uri.fromFile(fileCache.awaitFileDownload(url) ?: return FilePreviewUiState.NoPreview) + } + FilePreviewUiState.Pdf(uri) } contentType.startsWith("video") || contentType.startsWith("audio") -> { - val tempFile: File? = fileCache.awaitFileDownload(url) - tempFile?.let { - FilePreviewUiState.Media( - Uri.fromFile(it), - thumbnailUrl, - contentType, - displayName - ) - } ?: FilePreviewUiState.NoPreview + val uri = if (localPath != null) { + Uri.fromFile(File(localPath)) + } else { + Uri.fromFile(fileCache.awaitFileDownload(url) ?: return FilePreviewUiState.NoPreview) + } + FilePreviewUiState.Media(uri, thumbnailUrl, contentType, displayName) } contentType.startsWith("image") -> { - val tempFile: File? = fileCache.awaitFileDownload(url) - tempFile?.let { - FilePreviewUiState.Image( - displayName = displayName, - uri = Uri.fromFile(it) - ) - } ?: FilePreviewUiState.NoPreview + val uri = if (localPath != null) { + Uri.fromFile(File(localPath)) + } else { + Uri.fromFile(fileCache.awaitFileDownload(url) ?: return FilePreviewUiState.NoPreview) + } + FilePreviewUiState.Image(displayName = displayName, uri = uri) } - else -> FilePreviewUiState.WebView("$authUrl&preview=1") + else -> if (authUrl != null) { + FilePreviewUiState.WebView("$authUrl&preview=1") + } else { + FilePreviewUiState.NoPreview + } } } catch (e: Exception) { crashlytics.recordException(e) @@ -191,4 +213,4 @@ class FileDetailsViewModel @Inject constructor( } catch {} } } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsRepository.kt deleted file mode 100644 index 5399a28e5d..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsRepository.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (C) 2025 - 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.moduleitemsequence.content.page - -import com.apollographql.apollo.api.Optional -import com.instructure.canvasapi2.apis.OAuthAPI -import com.instructure.canvasapi2.apis.PageAPI -import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManager -import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.Page -import com.instructure.horizon.features.notebook.common.model.Note -import com.instructure.horizon.features.notebook.common.model.mapToNotes -import com.instructure.redwood.type.LearningObjectFilter -import com.instructure.redwood.type.NoteFilterInput -import javax.inject.Inject - -class PageDetailsRepository @Inject constructor( - private val pageApi: PageAPI.PagesInterface, - private val oAuthInterface: OAuthAPI.OAuthInterface, - private val redwoodApi: RedwoodApiManager, -) { - suspend fun getPageDetails(courseId: Long, pageId: String, forceNetwork: Boolean = false): Page { - val params = RestParams(isForceReadFromNetwork = forceNetwork) - return pageApi.getDetailedPage(CanvasContext.Type.COURSE.apiString, courseId, pageId, params).dataOrThrow - } - - suspend fun authenticateUrl(url: String): String { - return oAuthInterface.getAuthenticatedSession( - url, - RestParams(isForceReadFromNetwork = true) - ).dataOrNull?.sessionUrl ?: url - } - - suspend fun getNotes(courseId: Long, pageId: Long): List { - return redwoodApi.getNotes( - filter = NoteFilterInput( - courseId = Optional.present(courseId.toString()), - learningObject = Optional.present(LearningObjectFilter( - type = "Page", - id = pageId.toString() - )), - ), - firstN = null, - after = null, - ).mapToNotes() - } -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModel.kt index 99740bd22d..a82795b07b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModel.kt @@ -18,14 +18,22 @@ package com.instructure.horizon.features.moduleitemsequence.content.page import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.apollographql.apollo.api.Optional +import com.instructure.canvasapi2.apis.OAuthAPI +import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManager import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.domain.usecase.GetPageDetailsUseCase import com.instructure.horizon.features.moduleitemsequence.ModuleItemContent import com.instructure.horizon.features.notebook.addedit.add.AddNoteRepository import com.instructure.horizon.features.notebook.common.model.NotebookType +import com.instructure.horizon.features.notebook.common.model.mapToNotes import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.HtmlContentFormatter +import com.instructure.redwood.type.LearningObjectFilter +import com.instructure.redwood.type.NoteFilterInput import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -35,8 +43,10 @@ import javax.inject.Inject @HiltViewModel class PageDetailsViewModel @Inject constructor( - private val pageDetailsRepository: PageDetailsRepository, + private val getPageDetailsUseCase: GetPageDetailsUseCase, private val htmlContentFormatter: HtmlContentFormatter, + private val oAuthApi: OAuthAPI.OAuthInterface, + private val redwoodApi: RedwoodApiManager, private val addNoteRepository: AddNoteRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -63,10 +73,20 @@ class PageDetailsViewModel @Inject constructor( _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = true)) } - val pageDetails = pageDetailsRepository.getPageDetails(courseId, pageUrl) + val pageDetails = getPageDetailsUseCase(GetPageDetailsUseCase.Params(courseId, pageUrl)) val html = htmlContentFormatter.formatHtmlWithIframes(pageDetails.body.orEmpty(), courseId) - val notes = try { // We don't want to fail the page load if fetching notes fails - pageDetailsRepository.getNotes(courseId, pageDetails.id) + val notes = try { + redwoodApi.getNotes( + filter = NoteFilterInput( + courseId = Optional.present(courseId.toString()), + learningObject = Optional.present(LearningObjectFilter( + type = "Page", + id = pageDetails.id.toString() + )), + ), + firstN = null, + after = null, + ).mapToNotes() } catch (e: Exception) { emptyList() } @@ -79,9 +99,6 @@ class PageDetailsViewModel @Inject constructor( pageUrl = pageUrl ) } - _uiState.update { - it.copy(loadingState = it.loadingState.copy(isLoading = false)) - } } catch { _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = false, isError = true)) @@ -91,17 +108,28 @@ class PageDetailsViewModel @Inject constructor( fun refreshNotes() { viewModelScope.tryLaunch { - val notes = pageDetailsRepository.getNotes(uiState.value.courseId, uiState.value.pageId) + val notes = redwoodApi.getNotes( + filter = NoteFilterInput( + courseId = Optional.present(uiState.value.courseId.toString()), + learningObject = Optional.present(LearningObjectFilter( + type = "Page", + id = uiState.value.pageId.toString() + )), + ), + firstN = null, + after = null, + ).mapToNotes() _uiState.update { it.copy(notes = notes) } - } catch { } + } catch { } } private fun ltiButtonPressed(ltiUrl: String) { viewModelScope.launch { try { - val authenticatedSessionURL = - pageDetailsRepository.authenticateUrl(ltiUrl) - + val authenticatedSessionURL = oAuthApi.getAuthenticatedSession( + ltiUrl, + RestParams(isForceReadFromNetwork = true) + ).dataOrNull?.sessionUrl ?: ltiUrl _uiState.update { it.copy(urlToOpen = authenticatedSessionURL) } } catch (e: Exception) { _uiState.update { it.copy(urlToOpen = ltiUrl) } @@ -126,4 +154,4 @@ class PageDetailsViewModel @Inject constructor( refreshNotes() } catch {} } -} \ No newline at end of file +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepositoryTest.kt index de0f7890dc..2634cfacc6 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepositoryTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepositoryTest.kt @@ -16,16 +16,18 @@ */ package com.instructure.horizon.features.moduleitemsequence -import com.instructure.canvasapi2.apis.AssignmentAPI import com.instructure.canvasapi2.apis.ModuleAPI import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager -import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleItemSequence import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult +import com.instructure.horizon.data.repository.CourseRepository +import com.instructure.horizon.database.dao.HorizonCourseModuleDao +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -40,9 +42,12 @@ import org.junit.Test class ModuleItemSequenceRepositoryTest { private val moduleApi: ModuleAPI.ModuleInterface = mockk(relaxed = true) - private val assignmentApi: AssignmentAPI.AssignmentInterface = mockk(relaxed = true) + private val courseRepository: CourseRepository = mockk(relaxed = true) + private val courseModuleDao: HorizonCourseModuleDao = mockk(relaxed = true) private val horizonGetCommentsManager: HorizonGetCommentsManager = mockk(relaxed = true) private val apiPrefs: ApiPrefs = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) private val userId = 1L private val courseId = 1L @@ -50,6 +55,8 @@ class ModuleItemSequenceRepositoryTest { @Before fun setup() { every { apiPrefs.user } returns User(id = userId, name = "Test User") + // featureFlagProvider.offlineEnabled() returns false by default (relaxed mock), + // so shouldFetchFromNetwork() = isOnline() || !offlineEnabled() = false || true = true } @Test @@ -76,8 +83,7 @@ class ModuleItemSequenceRepositoryTest { ModuleItem(id = 1L, title = "Item 1"), ModuleItem(id = 2L, title = "Item 2") )) - coEvery { moduleApi.getFirstPageModulesWithItems(any(), any(), any(), any()) } returns - DataResult.Success(listOf(module)) + coEvery { courseRepository.getModuleItems(any(), any()) } returns listOf(module) val result = getRepository().getModulesWithItems(courseId, false) @@ -85,28 +91,6 @@ class ModuleItemSequenceRepositoryTest { assertEquals(module, result[0]) } - @Test - fun `Test modules with incomplete items fetches all items`() = runTest { - val incompleteModule = ModuleObject(id = 1L, name = "Module 1", itemCount = 3, items = listOf( - ModuleItem(id = 1L, title = "Item 1") - )) - val allItems = listOf( - ModuleItem(id = 1L, title = "Item 1"), - ModuleItem(id = 2L, title = "Item 2"), - ModuleItem(id = 3L, title = "Item 3") - ) - - coEvery { moduleApi.getFirstPageModulesWithItems(any(), any(), any(), any()) } returns - DataResult.Success(listOf(incompleteModule)) - coEvery { moduleApi.getFirstPageModuleItems(any(), any(), any(), any(), any()) } returns - DataResult.Success(allItems) - - val result = getRepository().getModulesWithItems(courseId, false) - - assertEquals(1, result.size) - assertEquals(3, result[0].items.size) - } - @Test fun `Test successful module item retrieval`() = runTest { val moduleItem = ModuleItem(id = 1L, title = "Item 1", moduleId = 1L) @@ -150,17 +134,6 @@ class ModuleItemSequenceRepositoryTest { coVerify { moduleApi.markModuleItemRead(any(), courseId, 1L, 1L, any()) } } - @Test - fun `Test successful assignment retrieval`() = runTest { - val assignment = Assignment(id = 1L, name = "Assignment 1") - coEvery { assignmentApi.getAssignmentWithHistory(any(), any(), any()) } returns - DataResult.Success(assignment) - - val result = getRepository().getAssignment(1L, courseId, false) - - assertEquals(assignment, result) - } - @Test fun `Test has unread comments returns true when count greater than zero`() = runTest { coEvery { horizonGetCommentsManager.getUnreadCommentsCount(any(), any(), any()) } returns 5 @@ -187,6 +160,14 @@ class ModuleItemSequenceRepositoryTest { } private fun getRepository(): ModuleItemSequenceRepository { - return ModuleItemSequenceRepository(moduleApi, assignmentApi, horizonGetCommentsManager, apiPrefs) + return ModuleItemSequenceRepository( + moduleApi, + courseRepository, + courseModuleDao, + horizonGetCommentsManager, + apiPrefs, + networkStateProvider, + featureFlagProvider, + ) } } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModelTest.kt index 46785f31b6..87013c0b85 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModelTest.kt @@ -23,6 +23,7 @@ import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleItemSequence import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.domain.usecase.GetAssignmentDetailsUseCase import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.horizon.features.learn.LearnEventHandler @@ -50,6 +51,7 @@ import org.junit.Test class ModuleItemSequenceViewModelTest { private val context: Context = mockk(relaxed = true) private val repository: ModuleItemSequenceRepository = mockk(relaxed = true) + private val getAssignmentDetailsUseCase: GetAssignmentDetailsUseCase = mockk(relaxed = true) private val moduleItemCardStateMapper: ModuleItemCardStateMapper = mockk(relaxed = true) private val aiAssistContextProvider: AiAssistContextProvider = mockk(relaxed = true) private val dashboardEventHandler: DashboardEventHandler = DashboardEventHandler() @@ -86,7 +88,7 @@ class ModuleItemSequenceViewModelTest { coEvery { repository.getModuleItemSequence(any(), any(), any()) } returns ModuleItemSequence() coEvery { repository.getModulesWithItems(any(), any()) } returns listOf(testModule) coEvery { repository.getModuleItem(any(), any(), any()) } returns testModuleItem - coEvery { repository.getAssignment(any(), any(), any()) } returns Assignment(id = 1L) + coEvery { getAssignmentDetailsUseCase(any()) } returns Assignment(id = 1L) coEvery { repository.hasUnreadComments(any(), any()) } returns false coEvery { moduleItemCardStateMapper.mapModuleItemToCardState(any(), any()) } returns mockk(relaxed = true) } @@ -124,17 +126,16 @@ class ModuleItemSequenceViewModelTest { @Test fun `Test assignment is fetched for module item`() = runTest { - coEvery { repository.getAssignment(any(), any(), any()) } returns Assignment(id = 123L, name = "Test Assignment") + coEvery { getAssignmentDetailsUseCase(any()) } returns Assignment(id = 123L, name = "Test Assignment") val viewModel = getViewModel(savedStateHandle) - coVerify { repository.getAssignment(any(), courseId, any()) } + coVerify { getAssignmentDetailsUseCase(any()) } } @Test fun `Test unread comments check is performed`() = runTest { - val assignment = Assignment(id = 123L) - coEvery { repository.getAssignment(any(), any(), any()) } returns assignment + coEvery { getAssignmentDetailsUseCase(any()) } returns Assignment(id = 123L) val viewModel = getViewModel(savedStateHandle) @@ -145,6 +146,7 @@ class ModuleItemSequenceViewModelTest { return ModuleItemSequenceViewModel( context, repository, + getAssignmentDetailsUseCase, moduleItemCardStateMapper, aiAssistContextProvider, savedStateHandle, diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentRepositoryTest.kt deleted file mode 100644 index 5ba75ef571..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentRepositoryTest.kt +++ /dev/null @@ -1,158 +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.moduleitemsequence.content.assessment - -import com.instructure.canvasapi2.apis.AssignmentAPI -import com.instructure.canvasapi2.apis.LaunchDefinitionsAPI -import com.instructure.canvasapi2.apis.OAuthAPI -import com.instructure.canvasapi2.models.Assignment -import com.instructure.canvasapi2.models.AuthenticatedSession -import com.instructure.canvasapi2.models.LTITool -import com.instructure.canvasapi2.utils.DataResult -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import io.mockk.unmockkAll -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Test - -class AssessmentRepositoryTest { - private val assignmentApi: AssignmentAPI.AssignmentInterface = mockk(relaxed = true) - private val oAuthInterface: OAuthAPI.OAuthInterface = mockk(relaxed = true) - private val launchDefinitionsApi: LaunchDefinitionsAPI.LaunchDefinitionsInterface = mockk(relaxed = true) - - private lateinit var repository: AssessmentRepository - - private val testAssignment = Assignment( - id = 1L, - name = "Test Quiz", - courseId = 100L, - url = "https://example.com/quiz/1" - ) - - private val testLTITool = LTITool( - url = "https://lti.example.com/tool", - id = 1 - ) - - @Before - fun setup() { - repository = AssessmentRepository(assignmentApi, oAuthInterface, launchDefinitionsApi) - } - - @After - fun tearDown() { - unmockkAll() - } - - @Test - fun `getAssignment returns assignment successfully`() = runTest { - coEvery { assignmentApi.getAssignmentWithHistory(any(), any(), any()) } returns DataResult.Success(testAssignment) - - val result = repository.getAssignment(assignmentId = 1L, courseId = 100L, forceNetwork = false) - - assertEquals("Test Quiz", result.name) - assertEquals(1L, result.id) - coVerify { assignmentApi.getAssignmentWithHistory(100L, 1L, any()) } - } - - @Test - fun `getAssignment with forceNetwork true`() = runTest { - coEvery { assignmentApi.getAssignmentWithHistory(any(), any(), any()) } returns DataResult.Success(testAssignment) - - repository.getAssignment(assignmentId = 1L, courseId = 100L, forceNetwork = true) - - coVerify { assignmentApi.getAssignmentWithHistory(any(), any(), match { it.isForceReadFromNetwork }) } - } - - @Test - fun `getAssignment with forceNetwork false`() = runTest { - coEvery { assignmentApi.getAssignmentWithHistory(any(), any(), any()) } returns DataResult.Success(testAssignment) - - repository.getAssignment(assignmentId = 1L, courseId = 100L, forceNetwork = false) - - coVerify { assignmentApi.getAssignmentWithHistory(any(), any(), match { !it.isForceReadFromNetwork }) } - } - - @Test - fun `authenticateUrl returns authenticated URL for LTI tool`() = runTest { - val session = AuthenticatedSession(sessionUrl = "https://authenticated.lti.url") - coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } returns DataResult.Success(testLTITool) - coEvery { oAuthInterface.getAuthenticatedSession(any(), any()) } returns DataResult.Success(session) - - val result = repository.authenticateUrl("https://example.com/quiz") - - assertEquals("https://authenticated.lti.url", result) - coVerify { launchDefinitionsApi.getLtiFromAuthenticationUrl("https://example.com/quiz", any()) } - coVerify { oAuthInterface.getAuthenticatedSession("https://lti.example.com/tool", any()) } - } - - @Test - fun `authenticateUrl returns original URL when LTI tool URL is null`() = runTest { - val ltiToolWithoutUrl = LTITool(url = null, id = 1) - coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } returns DataResult.Success(ltiToolWithoutUrl) - - val result = repository.authenticateUrl("https://example.com/quiz") - - assertEquals("https://example.com/quiz", result) - } - - @Test - fun `authenticateUrl returns original URL when session URL is null`() = runTest { - val session = AuthenticatedSession(sessionUrl = "https://example.com/quiz/authenticated") - coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } returns DataResult.Success(testLTITool) - coEvery { oAuthInterface.getAuthenticatedSession(any(), any()) } returns DataResult.Success(session) - - val result = repository.authenticateUrl("https://example.com/quiz") - - assertEquals("https://example.com/quiz/authenticated", result) - } - - @Test - fun `authenticateUrl returns original URL when authentication fails`() = runTest { - coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } returns DataResult.Success(testLTITool) - coEvery { oAuthInterface.getAuthenticatedSession(any(), any()) } returns DataResult.Fail() - - val result = repository.authenticateUrl("https://example.com/quiz") - - assertEquals("https://example.com/quiz", result) - } - - @Test - fun `authenticateUrl always uses forceNetwork`() = runTest { - val session = AuthenticatedSession(sessionUrl = "https://authenticated.url") - coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } returns DataResult.Success(testLTITool) - coEvery { oAuthInterface.getAuthenticatedSession(any(), any()) } returns DataResult.Success(session) - - repository.authenticateUrl("https://example.com") - - coVerify { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), match { it.isForceReadFromNetwork }) } - coVerify { oAuthInterface.getAuthenticatedSession(any(), match { it.isForceReadFromNetwork }) } - } - - @Test - fun `getAssignment with different course and assignment IDs`() = runTest { - coEvery { assignmentApi.getAssignmentWithHistory(any(), any(), any()) } returns DataResult.Success(testAssignment) - - repository.getAssignment(assignmentId = 99L, courseId = 200L, forceNetwork = false) - - coVerify { assignmentApi.getAssignmentWithHistory(200L, 99L, any()) } - } -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt index 2c697376d7..05285e827f 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt @@ -17,17 +17,17 @@ package com.instructure.horizon.features.moduleitemsequence.content.page import androidx.lifecycle.SavedStateHandle -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataRange -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteObjectType +import com.instructure.canvasapi2.apis.OAuthAPI +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManager +import com.instructure.canvasapi2.models.AuthenticatedSession +import com.instructure.canvasapi2.models.DataResult import com.instructure.canvasapi2.models.Page +import com.instructure.horizon.domain.usecase.GetPageDetailsUseCase import com.instructure.horizon.features.moduleitemsequence.ModuleItemContent import com.instructure.horizon.features.notebook.addedit.add.AddNoteRepository -import com.instructure.horizon.features.notebook.common.model.Note -import com.instructure.horizon.features.notebook.common.model.NotebookType import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.HtmlContentFormatter +import com.instructure.redwood.QueryNotesQuery import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -51,8 +51,10 @@ import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class PageDetailsViewModelTest { - private val repository: PageDetailsRepository = mockk(relaxed = true) + private val getPageDetailsUseCase: GetPageDetailsUseCase = mockk(relaxed = true) private val htmlContentFormatter: HtmlContentFormatter = mockk(relaxed = true) + private val oAuthApi: OAuthAPI.OAuthInterface = mockk(relaxed = true) + private val redwoodApi: RedwoodApiManager = mockk(relaxed = true) private val addNoteRepository: AddNoteRepository = mockk(relaxed = true) private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() @@ -66,34 +68,27 @@ class PageDetailsViewModelTest { body = "

Test content

" ) - private val testNotes = listOf( - Note( - id = "1", - objectId = "1", - objectType = NoteObjectType.PAGE, - userText = "comment 1", - highlightedText = NoteHighlightedData( - selectedText = "highlighted text 1", - range = NoteHighlightedDataRange(1, 5, "start", "end"), - textPosition = NoteHighlightedDataTextPosition(1, 5) - ), - type = NotebookType.Important, - updatedAt = Date(), - courseId = 1, + private fun makeNoteNode(id: String, userText: String) = QueryNotesQuery.Node( + id = id, + userText = userText, + createdAt = Date(), + updatedAt = Date(), + rootAccountUuid = "", + userId = "1", + courseId = courseId.toString(), + objectId = testPage.id.toString(), + objectType = "Page", + reaction = listOf("Important"), + highlightData = null + ) + + private val testNotesResponse = QueryNotesQuery.Notes( + edges = listOf( + QueryNotesQuery.Edge(cursor = "cursor1", node = makeNoteNode("1", "comment 1")), + QueryNotesQuery.Edge(cursor = "cursor2", node = makeNoteNode("2", "comment 2")), ), - Note( - id = "2", - objectId = "1", - objectType = NoteObjectType.PAGE, - userText = "comment 2", - highlightedText = NoteHighlightedData( - selectedText = "highlighted text 2", - range = NoteHighlightedDataRange(10, 15, "start", "end"), - textPosition = NoteHighlightedDataTextPosition(10, 15) - ), - type = NotebookType.Confusing, - updatedAt = Date(), - courseId = 1, + pageInfo = QueryNotesQuery.PageInfo( + hasNextPage = false, hasPreviousPage = false, endCursor = null, startCursor = null ) ) @@ -102,9 +97,10 @@ class PageDetailsViewModelTest { Dispatchers.setMain(testDispatcher) every { savedStateHandle.get(Const.COURSE_ID) } returns courseId every { savedStateHandle.get(ModuleItemContent.Page.PAGE_URL) } returns pageUrl - coEvery { repository.getPageDetails(any(), any()) } returns testPage - coEvery { repository.getNotes(any(), any()) } returns testNotes - coEvery { repository.authenticateUrl(any()) } returns "https://authenticated.url" + coEvery { getPageDetailsUseCase(any()) } returns testPage + coEvery { redwoodApi.getNotes(any(), any(), any(), any(), any(), any(), any()) } returns testNotesResponse + coEvery { oAuthApi.getAuthenticatedSession(any(), any()) } returns + DataResult.Success(AuthenticatedSession(sessionUrl = "https://authenticated.url")) coEvery { htmlContentFormatter.formatHtmlWithIframes(any(), any()) } answers { firstArg() } coEvery { addNoteRepository.addNote(any(), any(), any(), any(), any(), any()) } returns Unit } @@ -123,7 +119,7 @@ class PageDetailsViewModelTest { assertEquals("

Test content

", viewModel.uiState.value.pageHtmlContent) assertEquals(100L, viewModel.uiState.value.pageId) assertEquals(pageUrl, viewModel.uiState.value.pageUrl) - coVerify { repository.getPageDetails(courseId, pageUrl) } + coVerify { getPageDetailsUseCase(GetPageDetailsUseCase.Params(courseId, pageUrl)) } } @Test @@ -142,12 +138,12 @@ class PageDetailsViewModelTest { assertEquals(2, viewModel.uiState.value.notes.size) assertEquals("comment 1", viewModel.uiState.value.notes.first().userText) - coVerify { repository.getNotes(courseId, 100L) } + coVerify { redwoodApi.getNotes(any(), any(), any(), any(), any(), any(), any()) } } @Test fun `Test notes loading failure does not fail page load`() = runTest { - coEvery { repository.getNotes(any(), any()) } throws Exception("Notes error") + coEvery { redwoodApi.getNotes(any(), any(), any(), any(), any(), any(), any()) } throws Exception("Notes error") val viewModel = getViewModel() @@ -163,12 +159,12 @@ class PageDetailsViewModelTest { viewModel.uiState.value.ltiButtonPressed?.invoke("https://lti.url") assertEquals("https://authenticated.url", viewModel.uiState.value.urlToOpen) - coVerify { repository.authenticateUrl("https://lti.url") } + coVerify { oAuthApi.getAuthenticatedSession("https://lti.url", any()) } } @Test fun `Test LTI authentication failure returns original URL`() = runTest { - coEvery { repository.authenticateUrl(any()) } throws Exception("Auth error") + coEvery { oAuthApi.getAuthenticatedSession(any(), any()) } throws Exception("Auth error") val viewModel = getViewModel() @@ -190,10 +186,10 @@ class PageDetailsViewModelTest { @Test fun `Test add note creates note and refreshes`() = runTest { val viewModel = getViewModel() - val highlightedData = NoteHighlightedData( + val highlightedData = com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData( selectedText = "highlighted text", - range = NoteHighlightedDataRange(1, 5, "start", "end"), - textPosition = NoteHighlightedDataTextPosition(1, 5) + range = com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataRange(1, 5, "start", "end"), + textPosition = com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition(1, 5) ) viewModel.uiState.value.addNote(highlightedData, "Important") @@ -204,16 +200,25 @@ class PageDetailsViewModelTest { objectType = "Page", highlightedData = highlightedData, userComment = "", - type = NotebookType.Important + type = com.instructure.horizon.features.notebook.common.model.NotebookType.Important ) } - coVerify(atLeast = 2) { repository.getNotes(courseId, 100L) } + coVerify(atLeast = 2) { redwoodApi.getNotes(any(), any(), any(), any(), any(), any(), any()) } } @Test fun `Test refresh notes updates state`() = runTest { - val updatedNotes = testNotes + testNotes.last().copy(userText = "New note") - coEvery { repository.getNotes(any(), any()) } returns testNotes andThen updatedNotes + val updatedNotesResponse = QueryNotesQuery.Notes( + edges = testNotesResponse.edges.orEmpty() + QueryNotesQuery.Edge( + cursor = "cursor3", + node = makeNoteNode("3", "New note") + ), + pageInfo = QueryNotesQuery.PageInfo( + hasNextPage = false, hasPreviousPage = false, endCursor = null, startCursor = null + ) + ) + coEvery { redwoodApi.getNotes(any(), any(), any(), any(), any(), any(), any()) } returnsMany + listOf(testNotesResponse, updatedNotesResponse) val viewModel = getViewModel() assertEquals(2, viewModel.uiState.value.notes.size) @@ -227,15 +232,14 @@ class PageDetailsViewModelTest { @Test fun `Test refresh notes handles error`() = runTest { val viewModel = getViewModel() - coEvery { repository.getNotes(any(), any()) } throws Exception("Error") + coEvery { redwoodApi.getNotes(any(), any(), any(), any(), any(), any(), any()) } throws Exception("Error") - // Should not crash viewModel.refreshNotes() } @Test fun `Test page load error sets error state`() = runTest { - coEvery { repository.getPageDetails(any(), any()) } throws Exception("Error") + coEvery { getPageDetailsUseCase(any()) } throws Exception("Error") val viewModel = getViewModel() @@ -252,8 +256,10 @@ class PageDetailsViewModelTest { private fun getViewModel(): PageDetailsViewModel { return PageDetailsViewModel( - repository, + getPageDetailsUseCase, htmlContentFormatter, + oAuthApi, + redwoodApi, addNoteRepository, savedStateHandle ) From 3b4ee86a9b8547130e0b6faf8d20752d68c9b13d Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Tue, 14 Apr 2026 08:39:33 +0200 Subject: [PATCH 24/31] Fix page handling --- .../horizon/data/datasource/CourseProgressLocalDataSource.kt | 2 ++ .../java/com/instructure/horizon/database/HorizonDatabase.kt | 2 +- .../horizon/database/entity/HorizonCourseModuleItemEntity.kt | 1 + .../features/moduleitemsequence/ModuleItemSequenceRepository.kt | 1 + .../com/instructure/pandautils/utils/FeatureFlagProvider.kt | 2 +- 5 files changed, 6 insertions(+), 2 deletions(-) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt index ac594a316e..cc1cad548d 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt @@ -98,6 +98,7 @@ class CourseProgressLocalDataSource @Inject constructor( moduleDetails = moduleDetails, quizLti = quizLti, estimatedDuration = estimatedDuration, + pageUrl = pageUrl, ) } @@ -134,6 +135,7 @@ class CourseProgressLocalDataSource @Inject constructor( unlockAt = moduleDetails?.unlockAt, quizLti = quizLti, estimatedDuration = estimatedDuration, + pageUrl = pageUrl, ) } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt index 8565698f4c..c7695c9630 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt @@ -75,7 +75,7 @@ import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity HorizonPageEntity::class, HorizonAssignmentDetailsEntity::class, ], - version = 8, + version = 9, ) abstract class HorizonDatabase : RoomDatabase() { abstract fun dashboardEnrollmentDao(): HorizonDashboardEnrollmentDao diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt index 941694d6b1..7415f2f4c7 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt @@ -54,4 +54,5 @@ data class HorizonCourseModuleItemEntity( // Other val quizLti: Boolean, val estimatedDuration: String?, + val pageUrl: String?, ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt index 09cc43324b..443c490cb4 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt @@ -153,6 +153,7 @@ class ModuleItemSequenceRepository @Inject constructor( type = type, htmlUrl = htmlUrl, url = url, + pageUrl = pageUrl, completionRequirement = completionRequirement, moduleDetails = moduleDetails, quizLti = quizLti, diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt index 14df17cf46..8d493122ff 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt @@ -53,7 +53,7 @@ class FeatureFlagProvider( } suspend fun offlineEnabled(): Boolean { - return checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary + return true//checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary } private suspend fun checkEnvironmentFeatureFlag(featureFlag: String): Boolean { From 6a18f52c10225931f4f64d674973c5e666aa75bd Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Tue, 14 Apr 2026 16:01:01 +0200 Subject: [PATCH 25/31] Sync improvement --- .../AssignmentCommentsLocalDataSource.kt | 100 +++++++++++ .../CourseProgressLocalDataSource.kt | 23 ++- .../datasource/SubmissionLocalDataSource.kt | 111 +++++++++++++ .../datasource/SubmissionNetworkDataSource.kt | 32 ++++ .../repository/AssignmentDetailsRepository.kt | 4 + .../data/repository/SubmissionRepository.kt | 48 ++++++ .../horizon/database/HorizonDatabase.kt | 14 +- .../dao/HorizonAssignmentCommentDao.kt | 61 +++++++ .../database/dao/HorizonSubmissionDao.kt | 60 +++++++ .../entity/HorizonAssignmentCommentEntity.kt | 64 ++++++++ .../entity/HorizonCourseModuleItemEntity.kt | 9 +- .../entity/HorizonSubmissionEntity.kt | 69 ++++++++ .../entity/HorizonSyncMetadataEntity.kt | 1 + .../horizon/di/HorizonOfflineModule.kt | 12 ++ .../domain/usecase/GetCommentsUseCase.kt | 48 ++++++ .../usecase/GetSubmissionHistoryUseCase.kt | 37 +++++ .../usecase/GetUnreadCommentsCountUseCase.kt | 35 ++++ .../ModuleItemSequenceRepository.kt | 20 ++- .../ModuleItemSequenceViewModel.kt | 1 + .../assignment/AssignmentDetailsViewModel.kt | 23 ++- .../assignment/comments/CommentsRepository.kt | 50 ++++-- .../ModuleItemSequenceRepositoryTest.kt | 10 +- .../AssignmentDetailsRepositoryTest.kt | 155 +++++++++--------- .../AssignmentDetailsViewModelTest.kt | 61 +++---- .../comments/CommentsRepositoryTest.kt | 8 +- 25 files changed, 911 insertions(+), 145 deletions(-) create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentCommentsLocalDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/SubmissionLocalDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/datasource/SubmissionNetworkDataSource.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/data/repository/SubmissionRepository.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonAssignmentCommentDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonSubmissionDao.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonAssignmentCommentEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSubmissionEntity.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetCommentsUseCase.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetSubmissionHistoryUseCase.kt create mode 100644 libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetUnreadCommentsCountUseCase.kt diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentCommentsLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentCommentsLocalDataSource.kt new file mode 100644 index 0000000000..d7398a2c56 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentCommentsLocalDataSource.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.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, + ): 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, + ) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt index cc1cad548d..3b9795d954 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt @@ -76,7 +76,10 @@ 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, @@ -84,21 +87,28 @@ class CourseProgressLocalDataSource @Inject constructor( 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, estimatedDuration = estimatedDuration, - pageUrl = pageUrl, ) } @@ -121,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, @@ -133,9 +149,10 @@ class CourseProgressLocalDataSource @Inject constructor( lockExplanation = moduleDetails?.lockExplanation, lockAt = moduleDetails?.lockAt, unlockAt = moduleDetails?.unlockAt, + hidden = moduleDetails?.hidden, + locked = moduleDetails?.locked, quizLti = quizLti, estimatedDuration = estimatedDuration, - pageUrl = pageUrl, ) } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/SubmissionLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/SubmissionLocalDataSource.kt new file mode 100644 index 0000000000..8c35da4530 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/SubmissionLocalDataSource.kt @@ -0,0 +1,111 @@ +/* + * 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.Attachment +import com.instructure.canvasapi2.models.Submission +import com.instructure.horizon.database.dao.HorizonSubmissionDao +import com.instructure.horizon.database.entity.HorizonSubmissionAttachmentEntity +import com.instructure.horizon.database.entity.HorizonSubmissionEntity +import java.util.Date +import javax.inject.Inject + +class SubmissionLocalDataSource @Inject constructor( + private val submissionDao: HorizonSubmissionDao, +) { + + suspend fun getSubmissions(assignmentId: Long): List { + val submissionEntities = submissionDao.getSubmissions(assignmentId) + if (submissionEntities.isEmpty()) return emptyList() + val attachmentsByAttempt = submissionDao.getAttachments(assignmentId).groupBy { it.attempt } + return submissionEntities.map { entity -> + entity.toSubmission(attachmentsByAttempt[entity.attempt] ?: emptyList()) + } + } + + suspend fun saveSubmissions(assignmentId: Long, submissions: List) { + val submissionEntities = submissions.map { it.toEntity(assignmentId) } + val attachmentEntities = submissions.flatMap { submission -> + submission.attachments.map { it.toEntity(assignmentId, submission.attempt) } + } + submissionDao.replaceForAssignment(assignmentId, submissionEntities, attachmentEntities) + } + + private fun HorizonSubmissionEntity.toSubmission( + attachments: List, + ): Submission { + return Submission( + id = submissionId, + assignmentId = assignmentId, + attempt = attempt, + grade = grade, + score = score, + submittedAt = submittedAtMs?.let { Date(it) }, + workflowState = workflowState, + submissionType = submissionType, + body = body, + url = url, + late = late, + excused = excused, + missing = missing, + customGradeStatusId = customGradeStatusId, + userId = userId, + attachments = ArrayList(attachments.map { it.toAttachment() }), + ) + } + + private fun HorizonSubmissionAttachmentEntity.toAttachment(): Attachment { + return Attachment( + id = id, + displayName = displayName, + url = url, + contentType = contentType, + thumbnailUrl = thumbnailUrl, + ) + } + + private fun Submission.toEntity(assignmentId: Long): HorizonSubmissionEntity { + return HorizonSubmissionEntity( + assignmentId = assignmentId, + attempt = attempt, + submissionId = id, + grade = grade, + score = score, + submittedAtMs = submittedAt?.time, + workflowState = workflowState, + submissionType = submissionType, + body = body, + url = url, + late = late, + excused = excused, + missing = missing, + customGradeStatusId = customGradeStatusId, + userId = userId, + ) + } + + private fun Attachment.toEntity(assignmentId: Long, attempt: Long): HorizonSubmissionAttachmentEntity { + return HorizonSubmissionAttachmentEntity( + id = id, + assignmentId = assignmentId, + attempt = attempt, + displayName = displayName, + url = url, + contentType = contentType, + thumbnailUrl = thumbnailUrl, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/SubmissionNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/SubmissionNetworkDataSource.kt new file mode 100644 index 0000000000..7dbeebb219 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/SubmissionNetworkDataSource.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.apis.SubmissionAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Submission +import javax.inject.Inject + +class SubmissionNetworkDataSource @Inject constructor( + private val submissionApi: SubmissionAPI.SubmissionInterface, +) { + + suspend fun getSubmissions(courseId: Long, assignmentId: Long, userId: Long, forceRefresh: Boolean): List { + val params = RestParams(isForceReadFromNetwork = forceRefresh) + val submission = submissionApi.getSingleSubmission(courseId, assignmentId, userId, params).dataOrThrow + return submission.submissionHistory.filterNotNull() + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/AssignmentDetailsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/AssignmentDetailsRepository.kt index ca3ee76b66..0a12bdad31 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/AssignmentDetailsRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/AssignmentDetailsRepository.kt @@ -18,6 +18,7 @@ package com.instructure.horizon.data.repository import com.instructure.canvasapi2.models.Assignment import com.instructure.horizon.data.datasource.AssignmentDetailsLocalDataSource import com.instructure.horizon.data.datasource.AssignmentDetailsNetworkDataSource +import com.instructure.horizon.data.datasource.SubmissionLocalDataSource import com.instructure.horizon.di.HorizonHtmlParserQualifier import com.instructure.horizon.offline.OfflineSyncRepository import com.instructure.pandautils.features.offline.sync.HtmlParser @@ -28,6 +29,7 @@ import javax.inject.Inject class AssignmentDetailsRepository @Inject constructor( private val networkDataSource: AssignmentDetailsNetworkDataSource, private val localDataSource: AssignmentDetailsLocalDataSource, + private val submissionLocalDataSource: SubmissionLocalDataSource, @HorizonHtmlParserQualifier private val htmlParser: HtmlParser, private val fileSyncRepository: HorizonFileSyncRepository, networkStateProvider: NetworkStateProvider, @@ -41,6 +43,8 @@ class AssignmentDetailsRepository @Inject constructor( val parsingResult = htmlParser.createHtmlStringWithLocalFiles(assignment.description, courseId) localDataSource.saveAssignment(assignment, courseId, parsingResult.htmlWithLocalFileLinks) fileSyncRepository.syncHtmlFiles(courseId, parsingResult) + val submissionHistory = assignment.submission?.submissionHistory?.filterNotNull() ?: emptyList() + submissionLocalDataSource.saveSubmissions(assignment.id, submissionHistory) } } } else { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/SubmissionRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/SubmissionRepository.kt new file mode 100644 index 0000000000..1b71575958 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/SubmissionRepository.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.models.Submission +import com.instructure.horizon.data.datasource.SubmissionLocalDataSource +import com.instructure.horizon.data.datasource.SubmissionNetworkDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class SubmissionRepository @Inject constructor( + private val networkDataSource: SubmissionNetworkDataSource, + private val localDataSource: SubmissionLocalDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getSubmissions(courseId: Long, assignmentId: Long, userId: Long, forceRefresh: Boolean = false): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getSubmissions(courseId, assignmentId, userId, forceRefresh).also { submissions -> + if (shouldSync()) { + localDataSource.saveSubmissions(assignmentId, submissions) + } + } + } else { + localDataSource.getSubmissions(assignmentId) + } + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt index c7695c9630..14a9db0023 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt @@ -18,7 +18,9 @@ package com.instructure.horizon.database import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import com.instructure.horizon.database.dao.HorizonAssignmentCommentDao import com.instructure.horizon.database.dao.HorizonAssignmentDetailsDao +import com.instructure.horizon.database.dao.HorizonSubmissionDao import com.instructure.horizon.database.dao.HorizonCourseDao import com.instructure.horizon.database.dao.HorizonCourseModuleDao import com.instructure.horizon.database.dao.HorizonCourseScoreDao @@ -32,7 +34,11 @@ 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.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonAssignmentCommentAttachmentEntity +import com.instructure.horizon.database.entity.HorizonAssignmentCommentEntity import com.instructure.horizon.database.entity.HorizonAssignmentDetailsEntity +import com.instructure.horizon.database.entity.HorizonSubmissionAttachmentEntity +import com.instructure.horizon.database.entity.HorizonSubmissionEntity import com.instructure.horizon.database.entity.HorizonCourseAssignmentEntity import com.instructure.horizon.database.entity.HorizonCourseAssignmentGroupEntity import com.instructure.horizon.database.entity.HorizonCourseEntity @@ -74,8 +80,12 @@ import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity HorizonFileFolderEntity::class, HorizonPageEntity::class, HorizonAssignmentDetailsEntity::class, + HorizonAssignmentCommentEntity::class, + HorizonAssignmentCommentAttachmentEntity::class, + HorizonSubmissionEntity::class, + HorizonSubmissionAttachmentEntity::class, ], - version = 9, + version = 13, ) abstract class HorizonDatabase : RoomDatabase() { abstract fun dashboardEnrollmentDao(): HorizonDashboardEnrollmentDao @@ -92,4 +102,6 @@ abstract class HorizonDatabase : RoomDatabase() { abstract fun fileFolderDao(): HorizonFileFolderDao abstract fun pageDao(): HorizonPageDao abstract fun assignmentDetailsDao(): HorizonAssignmentDetailsDao + abstract fun assignmentCommentDao(): HorizonAssignmentCommentDao + abstract fun submissionDao(): HorizonSubmissionDao } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonAssignmentCommentDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonAssignmentCommentDao.kt new file mode 100644 index 0000000000..5877b8283e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonAssignmentCommentDao.kt @@ -0,0 +1,61 @@ +/* + * 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.HorizonAssignmentCommentAttachmentEntity +import com.instructure.horizon.database.entity.HorizonAssignmentCommentEntity + +@Dao +interface HorizonAssignmentCommentDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertComment(comment: HorizonAssignmentCommentEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAttachments(attachments: List) + + @Query("SELECT * FROM horizon_assignment_comments WHERE assignmentId = :assignmentId AND attempt = :attempt ORDER BY createdAtMs ASC") + suspend fun getComments(assignmentId: Long, attempt: Int): List + + @Query("SELECT * FROM horizon_assignment_comment_attachments WHERE commentId IN (:commentIds)") + suspend fun getAttachments(commentIds: List): List + + @Query("SELECT COUNT(*) FROM horizon_assignment_comments WHERE assignmentId = :assignmentId AND read = 0") + suspend fun getUnreadCommentCount(assignmentId: Long): Int + + @Query("DELETE FROM horizon_assignment_comments WHERE assignmentId = :assignmentId AND attempt = :attempt") + suspend fun deleteCommentsForAttempt(assignmentId: Long, attempt: Int) + + @Transaction + suspend fun replaceCommentsForAttempt( + assignmentId: Long, + attempt: Int, + commentWithAttachments: List>>, + ) { + deleteCommentsForAttempt(assignmentId, attempt) + for ((comment, attachments) in commentWithAttachments) { + val commentId = insertComment(comment) + if (attachments.isNotEmpty()) { + insertAttachments(attachments.map { it.copy(commentId = commentId) }) + } + } + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonSubmissionDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonSubmissionDao.kt new file mode 100644 index 0000000000..0fff796d66 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonSubmissionDao.kt @@ -0,0 +1,60 @@ +/* + * 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.HorizonSubmissionAttachmentEntity +import com.instructure.horizon.database.entity.HorizonSubmissionEntity + +@Dao +interface HorizonSubmissionDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSubmissions(submissions: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAttachments(attachments: List) + + @Query("SELECT * FROM horizon_submissions WHERE assignmentId = :assignmentId ORDER BY attempt ASC") + suspend fun getSubmissions(assignmentId: Long): List + + @Query("SELECT * FROM horizon_submission_attachments WHERE assignmentId = :assignmentId") + suspend fun getAttachments(assignmentId: Long): List + + @Query("DELETE FROM horizon_submissions WHERE assignmentId = :assignmentId") + suspend fun deleteSubmissionsForAssignment(assignmentId: Long) + + @Query("DELETE FROM horizon_submission_attachments WHERE assignmentId = :assignmentId") + suspend fun deleteAttachmentsForAssignment(assignmentId: Long) + + @Transaction + suspend fun replaceForAssignment( + assignmentId: Long, + submissions: List, + attachments: List, + ) { + deleteAttachmentsForAssignment(assignmentId) + deleteSubmissionsForAssignment(assignmentId) + insertSubmissions(submissions) + if (attachments.isNotEmpty()) { + insertAttachments(attachments) + } + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonAssignmentCommentEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonAssignmentCommentEntity.kt new file mode 100644 index 0000000000..4c64e4b707 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonAssignmentCommentEntity.kt @@ -0,0 +1,64 @@ +/* + * 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.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Stores a single submission comment for offline access. + * Comments are grouped by [assignmentId] and [attempt]. + * [createdAtMs] stores [java.util.Date.getTime] for sorting. + */ +@Entity( + tableName = "horizon_assignment_comments", + indices = [Index("assignmentId", "attempt")] +) +data class HorizonAssignmentCommentEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val assignmentId: Long, + val attempt: Int, + val authorId: Long, + val authorName: String, + val commentText: String, + val createdAtMs: Long, + val read: Boolean, +) + +/** + * Stores attachments for a [HorizonAssignmentCommentEntity]. + */ +@Entity( + tableName = "horizon_assignment_comment_attachments", + indices = [Index("commentId")], + foreignKeys = [ + ForeignKey( + entity = HorizonAssignmentCommentEntity::class, + parentColumns = ["id"], + childColumns = ["commentId"], + onDelete = ForeignKey.CASCADE, + ) + ] +) +data class HorizonAssignmentCommentAttachmentEntity( + @PrimaryKey val attachmentId: Long, + val commentId: Long, + val fileName: String, + val fileUrl: String, + val fileType: String, +) \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt index 7415f2f4c7..e5741a80d5 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt @@ -37,9 +37,15 @@ data class HorizonCourseModuleItemEntity( val courseId: Long, val title: String?, val position: Int, + val indent: Int, val type: String?, val htmlUrl: String?, val url: String?, + val contentId: Long, + val externalUrl: String?, + val pageUrl: String?, + val published: Boolean?, + val unpublishable: Boolean, // ModuleCompletionRequirement flattened val completionRequirementType: String?, val completionRequirementMinScore: Double, @@ -51,8 +57,9 @@ data class HorizonCourseModuleItemEntity( val lockExplanation: String?, val lockAt: String?, val unlockAt: String?, + val hidden: Boolean?, + val locked: Boolean?, // Other val quizLti: Boolean, val estimatedDuration: String?, - val pageUrl: String?, ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSubmissionEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSubmissionEntity.kt new file mode 100644 index 0000000000..08ca992ea7 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSubmissionEntity.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.database.entity + +import androidx.room.Entity +import androidx.room.Index + +/** + * Stores a single submission attempt for offline access. + * Primary key is (assignmentId, attempt) because all history entries for the same + * assignment share the same Canvas submission ID — only [attempt] differs. + * [submissionId] is the Canvas submission ID, kept as a regular field. + * File attachments are stored in [HorizonSubmissionAttachmentEntity]. + */ +@Entity( + tableName = "horizon_submissions", + primaryKeys = ["assignmentId", "attempt"], + indices = [Index("assignmentId")] +) +data class HorizonSubmissionEntity( + val assignmentId: Long, + val attempt: Long, + val submissionId: Long, + val grade: String?, + val score: Double, + val submittedAtMs: Long?, + val workflowState: String?, + val submissionType: String?, + val body: String?, + val url: String?, + val late: Boolean, + val excused: Boolean, + val missing: Boolean, + val customGradeStatusId: Long?, + val userId: Long, +) + +/** + * Stores file attachments for a submission attempt. + * Linked via [assignmentId] + [attempt] to [HorizonSubmissionEntity]. + * No FK constraint because [replaceForAssignment] handles cleanup via explicit DELETE. + */ +@Entity( + tableName = "horizon_submission_attachments", + primaryKeys = ["id"], + indices = [Index("assignmentId", "attempt")] +) +data class HorizonSubmissionAttachmentEntity( + val id: Long, + val assignmentId: Long, + val attempt: Long, + val displayName: String?, + val url: String?, + val contentType: String?, + val thumbnailUrl: String?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt index d1b21c3dbc..8e1ab15923 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt @@ -29,6 +29,7 @@ enum class SyncDataType { COURSE_DETAILS, COURSE_MODULES, COURSE_SCORES, + ASSIGNMENT_COMMENTS, } @Entity(tableName = "horizon_sync_metadata") diff --git a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt index 489a5c298e..1662ade86b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt @@ -22,7 +22,9 @@ import com.instructure.horizon.database.HorizonDatabaseProvider import com.instructure.horizon.offline.HorizonHtmlParserFileSource import com.instructure.pandautils.features.offline.sync.HtmlParser import dagger.hilt.android.qualifiers.ApplicationContext +import com.instructure.horizon.database.dao.HorizonAssignmentCommentDao import com.instructure.horizon.database.dao.HorizonAssignmentDetailsDao +import com.instructure.horizon.database.dao.HorizonSubmissionDao import com.instructure.horizon.database.dao.HorizonCourseDao import com.instructure.horizon.database.dao.HorizonCourseModuleDao import com.instructure.horizon.database.dao.HorizonCourseScoreDao @@ -123,6 +125,16 @@ class HorizonOfflineModule { return db.assignmentDetailsDao() } + @Provides + fun provideHorizonAssignmentCommentDao(db: HorizonDatabase): HorizonAssignmentCommentDao { + return db.assignmentCommentDao() + } + + @Provides + fun provideHorizonSubmissionDao(db: HorizonDatabase): HorizonSubmissionDao { + return db.submissionDao() + } + @Provides @HorizonHtmlParserQualifier fun provideHorizonHtmlParser( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetCommentsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetCommentsUseCase.kt new file mode 100644 index 0000000000..cd25cd53ea --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetCommentsUseCase.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.managers.graphql.horizon.CommentsData +import com.instructure.horizon.features.moduleitemsequence.content.assignment.comments.CommentsRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetCommentsUseCase @Inject constructor( + private val repository: CommentsRepository, +) : BaseUseCase() { + + data class Params( + val assignmentId: Long, + val userId: Long, + val attempt: Int, + val forceNetwork: Boolean = false, + val startCursor: String? = null, + val endCursor: String? = null, + val nextPage: Boolean = false, + ) + + override suspend fun execute(params: Params): CommentsData { + return repository.getComments( + assignmentId = params.assignmentId, + userId = params.userId, + attempt = params.attempt, + forceNetwork = params.forceNetwork, + startCursor = params.startCursor, + endCursor = params.endCursor, + nextPage = params.nextPage, + ) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetSubmissionHistoryUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetSubmissionHistoryUseCase.kt new file mode 100644 index 0000000000..51308c932a --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetSubmissionHistoryUseCase.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.domain.usecase + +import com.instructure.canvasapi2.models.Submission +import com.instructure.horizon.data.repository.SubmissionRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetSubmissionHistoryUseCase @Inject constructor( + private val repository: SubmissionRepository, +) : BaseUseCase>() { + + data class Params( + val courseId: Long, + val assignmentId: Long, + val userId: Long, + val forceRefresh: Boolean = false, + ) + + override suspend fun execute(params: Params): List { + return repository.getSubmissions(params.courseId, params.assignmentId, params.userId, params.forceRefresh) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetUnreadCommentsCountUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetUnreadCommentsCountUseCase.kt new file mode 100644 index 0000000000..479ca6ecfe --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetUnreadCommentsCountUseCase.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.horizon.features.moduleitemsequence.content.assignment.comments.CommentsRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetUnreadCommentsCountUseCase @Inject constructor( + private val repository: CommentsRepository, +) : BaseUseCase() { + + data class Params( + val assignmentId: Long, + val userId: Long, + val forceNetwork: Boolean = false, + ) + + override suspend fun execute(params: Params): Int { + return repository.getUnreadCommentCount(params.assignmentId, params.userId, params.forceNetwork) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt index 443c490cb4..0c6b13e7ff 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt @@ -17,7 +17,6 @@ package com.instructure.horizon.features.moduleitemsequence import com.instructure.canvasapi2.apis.ModuleAPI import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.ModuleCompletionRequirement import com.instructure.canvasapi2.models.ModuleContentDetails @@ -30,6 +29,7 @@ import com.instructure.canvasapi2.utils.DataResult import com.instructure.horizon.data.repository.CourseRepository import com.instructure.horizon.database.dao.HorizonCourseModuleDao import com.instructure.horizon.database.entity.HorizonCourseModuleItemEntity +import com.instructure.horizon.domain.usecase.GetUnreadCommentsCountUseCase import com.instructure.horizon.offline.OfflineSyncRepository import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.NetworkStateProvider @@ -41,7 +41,7 @@ class ModuleItemSequenceRepository @Inject constructor( private val moduleApi: ModuleAPI.ModuleInterface, private val courseRepository: CourseRepository, private val courseModuleDao: HorizonCourseModuleDao, - private val horizonGetCommentsManager: HorizonGetCommentsManager, + private val getUnreadCommentsCountUseCase: GetUnreadCommentsCountUseCase, private val apiPrefs: ApiPrefs, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, @@ -124,7 +124,9 @@ class ModuleItemSequenceRepository @Inject constructor( suspend fun hasUnreadComments(assignmentId: Long?, forceNetwork: Boolean = false): Boolean { if (assignmentId == null) return false - return horizonGetCommentsManager.getUnreadCommentsCount(assignmentId, apiPrefs.user?.id.orDefault(), forceNetwork) > 0 + return getUnreadCommentsCountUseCase( + GetUnreadCommentsCountUseCase.Params(assignmentId, apiPrefs.user?.id.orDefault(), forceNetwork) + ) > 0 } private fun HorizonCourseModuleItemEntity.toModuleItem(): ModuleItem { @@ -135,7 +137,10 @@ class ModuleItemSequenceRepository @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, @@ -143,17 +148,24 @@ class ModuleItemSequenceRepository @Inject constructor( 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, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt index 4daee9e2d3..1bc6a4ed02 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt @@ -297,6 +297,7 @@ class ModuleItemSequenceViewModel @Inject constructor( private fun loadModuleItem(position: Int, moduleItemId: Long) { _uiState.update { it.copy( + loadingState = it.loadingState.copy(isError = false), notebookButtonEnabled = false, aiAssistButtonEnabled = false ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModel.kt index a078b768cf..50e48d5970 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModel.kt @@ -21,7 +21,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.apis.OAuthAPI import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Submission import com.instructure.canvasapi2.utils.ApiPrefs @@ -29,6 +28,8 @@ import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R import com.instructure.horizon.domain.usecase.GetAssignmentDetailsUseCase +import com.instructure.horizon.domain.usecase.GetSubmissionHistoryUseCase +import com.instructure.horizon.domain.usecase.GetUnreadCommentsCountUseCase import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider import com.instructure.horizon.features.aiassistant.common.model.AiAssistContext import com.instructure.horizon.features.aiassistant.common.model.AiAssistContextSource @@ -51,9 +52,10 @@ import javax.inject.Inject class AssignmentDetailsViewModel @Inject constructor( @ApplicationContext private val context: Context, private val getAssignmentDetailsUseCase: GetAssignmentDetailsUseCase, + private val getSubmissionHistoryUseCase: GetSubmissionHistoryUseCase, + private val getUnreadCommentsCountUseCase: GetUnreadCommentsCountUseCase, private val htmlContentFormatter: HtmlContentFormatter, private val oAuthApi: OAuthAPI.OAuthInterface, - private val horizonGetCommentsManager: HorizonGetCommentsManager, private val apiPrefs: ApiPrefs, private val aiAssistContextProvider: AiAssistContextProvider, savedStateHandle: SavedStateHandle @@ -94,8 +96,8 @@ class AssignmentDetailsViewModel @Inject constructor( viewModelScope.tryLaunch { val assignment = getAssignmentDetailsUseCase(GetAssignmentDetailsUseCase.Params(courseId, assignmentId, forceRefresh = false)) _assignmentFlow.value = assignment - val lastActualSubmission = assignment.lastGradedOrSubmittedSubmission - val attempts = assignment.submission?.submissionHistory?.filterNotNull() ?: emptyList() + val attempts = getSubmissionHistoryUseCase(GetSubmissionHistoryUseCase.Params(courseId, assignmentId, apiPrefs.user?.id.orDefault())) + val lastActualSubmission = attempts.lastGradedOrSubmitted() val submissions = if (lastActualSubmission != null) { mapSubmissions(attempts) } else { @@ -107,7 +109,9 @@ class AssignmentDetailsViewModel @Inject constructor( val attemptsUiState = createAttemptCardsState(attempts, assignment, initialAttempt) val showAttemptSelector = assignment.allowedAttempts != 1L - val hasUnreadComments = horizonGetCommentsManager.getUnreadCommentsCount(assignmentId, apiPrefs.user?.id.orDefault(), false) > 0 + val hasUnreadComments = getUnreadCommentsCountUseCase( + GetUnreadCommentsCountUseCase.Params(assignmentId, apiPrefs.user?.id.orDefault()) + ) > 0 aiAssistContextProvider.aiAssistContext = AiAssistContext( contextString = assignment.description.orEmpty(), @@ -254,8 +258,8 @@ class AssignmentDetailsViewModel @Inject constructor( private suspend fun updateAssignment() { val assignment = getAssignmentDetailsUseCase(GetAssignmentDetailsUseCase.Params(courseId, assignmentId, forceRefresh = true)) _assignmentFlow.value = assignment - val lastActualSubmission = assignment.lastGradedOrSubmittedSubmission - val attempts = assignment.submission?.submissionHistory?.filterNotNull() ?: emptyList() + val attempts = getSubmissionHistoryUseCase(GetSubmissionHistoryUseCase.Params(courseId, assignmentId, apiPrefs.user?.id.orDefault(), forceRefresh = true)) + val lastActualSubmission = attempts.lastGradedOrSubmitted() val submissions = if (lastActualSubmission != null) { mapSubmissions(attempts) } else { @@ -358,6 +362,11 @@ class AssignmentDetailsViewModel @Inject constructor( } } + private fun List.lastGradedOrSubmitted(): Submission? { + return filter { it.workflowState == "graded" || it.workflowState == "submitted" } + .maxByOrNull { it.attempt } + } + private fun formatScore(value: Double): String { val formatter = NumberFormat.getNumberInstance().apply { maximumFractionDigits = 2 diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepository.kt index 7cd9f376f5..5a19b9e974 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepository.kt @@ -21,12 +21,20 @@ import com.instructure.canvasapi2.managers.graphql.horizon.CommentsData import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager import com.instructure.canvasapi2.models.Submission import com.instructure.canvasapi2.utils.DataResult +import com.instructure.horizon.data.datasource.AssignmentCommentsLocalDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import javax.inject.Inject class CommentsRepository @Inject constructor( private val getCommentsManager: HorizonGetCommentsManager, - private val submissionApi: SubmissionAPI.SubmissionInterface -) { + private val submissionApi: SubmissionAPI.SubmissionInterface, + private val localDataSource: AssignmentCommentsLocalDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + suspend fun getComments( assignmentId: Long, userId: Long, @@ -36,15 +44,31 @@ class CommentsRepository @Inject constructor( endCursor: String? = null, nextPage: Boolean = false ): CommentsData { - return getCommentsManager.getComments( - assignmentId = assignmentId, - userId = userId, - attempt = attempt, - forceNetwork = forceNetwork, - startCursor = startCursor, - endCursor = endCursor, - nextPage = nextPage - ) + return if (shouldFetchFromNetwork()) { + val commentsData = getCommentsManager.getComments( + assignmentId = assignmentId, + userId = userId, + attempt = attempt, + forceNetwork = forceNetwork, + startCursor = startCursor, + endCursor = endCursor, + nextPage = nextPage + ) + if (shouldSync() && startCursor == null && endCursor == null && !nextPage) { + localDataSource.saveComments(assignmentId, attempt, commentsData) + } + commentsData + } else { + localDataSource.getComments(assignmentId, attempt) + } + } + + suspend fun getUnreadCommentCount(assignmentId: Long, userId: Long, forceNetwork: Boolean): Int { + return if (shouldFetchFromNetwork()) { + getCommentsManager.getUnreadCommentsCount(assignmentId, userId, forceNetwork) + } else { + localDataSource.getUnreadCommentCount(assignmentId) + } } suspend fun postComment( @@ -65,4 +89,8 @@ class CommentsRepository @Inject constructor( RestParams() ) } + + override suspend fun sync() { + TODO("Not yet implemented") + } } \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepositoryTest.kt index 2634cfacc6..c266f066cd 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepositoryTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepositoryTest.kt @@ -17,7 +17,6 @@ package com.instructure.horizon.features.moduleitemsequence import com.instructure.canvasapi2.apis.ModuleAPI -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleItemSequence import com.instructure.canvasapi2.models.ModuleObject @@ -26,6 +25,7 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult import com.instructure.horizon.data.repository.CourseRepository import com.instructure.horizon.database.dao.HorizonCourseModuleDao +import com.instructure.horizon.domain.usecase.GetUnreadCommentsCountUseCase import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery @@ -44,7 +44,7 @@ class ModuleItemSequenceRepositoryTest { private val moduleApi: ModuleAPI.ModuleInterface = mockk(relaxed = true) private val courseRepository: CourseRepository = mockk(relaxed = true) private val courseModuleDao: HorizonCourseModuleDao = mockk(relaxed = true) - private val horizonGetCommentsManager: HorizonGetCommentsManager = mockk(relaxed = true) + private val getUnreadCommentsCountUseCase: GetUnreadCommentsCountUseCase = mockk(relaxed = true) private val apiPrefs: ApiPrefs = mockk(relaxed = true) private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) @@ -136,7 +136,7 @@ class ModuleItemSequenceRepositoryTest { @Test fun `Test has unread comments returns true when count greater than zero`() = runTest { - coEvery { horizonGetCommentsManager.getUnreadCommentsCount(any(), any(), any()) } returns 5 + coEvery { getUnreadCommentsCountUseCase(any()) } returns 5 val result = getRepository().hasUnreadComments(1L, false) @@ -145,7 +145,7 @@ class ModuleItemSequenceRepositoryTest { @Test fun `Test has unread comments returns false when count is zero`() = runTest { - coEvery { horizonGetCommentsManager.getUnreadCommentsCount(any(), any(), any()) } returns 0 + coEvery { getUnreadCommentsCountUseCase(any()) } returns 0 val result = getRepository().hasUnreadComments(1L, false) @@ -164,7 +164,7 @@ class ModuleItemSequenceRepositoryTest { moduleApi, courseRepository, courseModuleDao, - horizonGetCommentsManager, + getUnreadCommentsCountUseCase, apiPrefs, networkStateProvider, featureFlagProvider, diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepositoryTest.kt index 6f06cfa94d..9685555d94 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepositoryTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepositoryTest.kt @@ -16,122 +16,121 @@ */ package com.instructure.horizon.features.moduleitemsequence.content.assignment -import com.instructure.canvasapi2.apis.AssignmentAPI -import com.instructure.canvasapi2.apis.OAuthAPI -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager import com.instructure.canvasapi2.models.Assignment -import com.instructure.canvasapi2.models.AuthenticatedSession -import com.instructure.canvasapi2.models.User -import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.canvasapi2.utils.DataResult +import com.instructure.canvasapi2.models.Submission +import com.instructure.horizon.data.datasource.AssignmentDetailsLocalDataSource +import com.instructure.horizon.data.datasource.AssignmentDetailsNetworkDataSource +import com.instructure.horizon.data.datasource.SubmissionLocalDataSource +import com.instructure.horizon.data.repository.AssignmentDetailsRepository +import com.instructure.horizon.data.repository.HorizonFileSyncRepository +import com.instructure.pandautils.features.offline.sync.HtmlParser +import com.instructure.pandautils.features.offline.sync.HtmlParsingResult +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery -import io.mockk.every +import io.mockk.coVerify import io.mockk.mockk +import io.mockk.unmockkAll import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertFalse -import junit.framework.TestCase.assertTrue import kotlinx.coroutines.test.runTest -import org.junit.Before +import org.junit.After import org.junit.Test class AssignmentDetailsRepositoryTest { - private val assignmentApi: AssignmentAPI.AssignmentInterface = mockk(relaxed = true) - private val oAuthInterface: OAuthAPI.OAuthInterface = mockk(relaxed = true) - private val horizonGetCommentsManager: HorizonGetCommentsManager = mockk(relaxed = true) - private val apiPrefs: ApiPrefs = mockk(relaxed = true) + private val networkDataSource: AssignmentDetailsNetworkDataSource = mockk(relaxed = true) + private val localDataSource: AssignmentDetailsLocalDataSource = mockk(relaxed = true) + private val submissionLocalDataSource: SubmissionLocalDataSource = mockk(relaxed = true) + private val htmlParser: HtmlParser = mockk(relaxed = true) + private val fileSyncRepository: HorizonFileSyncRepository = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) - private val userId = 1L private val courseId = 1L - private val assignmentId = 1L + private val assignmentId = 10L + private val testAssignment = Assignment(id = assignmentId, name = "Test Assignment", pointsPossible = 100.0) - @Before - fun setup() { - every { apiPrefs.user } returns User(id = userId, name = "Test User") + @After + fun tearDown() { + unmockkAll() } @Test - fun `Test successful assignment retrieval`() = runTest { - val assignment = Assignment(id = assignmentId, name = "Test Assignment", pointsPossible = 100.0) - coEvery { assignmentApi.getAssignmentWithHistory(courseId, assignmentId, any()) } returns - DataResult.Success(assignment) + fun `getAssignment fetches from network when online`() = runTest { + coEvery { networkDataSource.getAssignment(courseId, assignmentId, false) } returns testAssignment + coEvery { htmlParser.createHtmlStringWithLocalFiles(any(), any()) } returns HtmlParsingResult("", emptySet(), emptySet(), emptySet()) - val result = getRepository().getAssignment(assignmentId, courseId, false) + val result = getRepository().getAssignment(courseId, assignmentId, false) - assertEquals(assignment, result) + assertEquals(testAssignment, result) + coVerify { networkDataSource.getAssignment(courseId, assignmentId, false) } } @Test(expected = IllegalStateException::class) - fun `Test failed assignment retrieval throws exception`() = runTest { - coEvery { assignmentApi.getAssignmentWithHistory(courseId, assignmentId, any()) } returns - DataResult.Fail() + fun `getAssignment throws when offline and no cached data`() = runTest { + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { networkStateProvider.isOnline() } returns false + coEvery { localDataSource.getAssignment(assignmentId) } returns null - getRepository().getAssignment(assignmentId, courseId, false) + getRepository().getAssignment(courseId, assignmentId, false) } @Test - fun `Test successful URL authentication`() = runTest { - val originalUrl = "https://example.com/file" - val authenticatedUrl = "https://example.com/file?session=xyz" - val session = AuthenticatedSession(sessionUrl = authenticatedUrl) + fun `getAssignment returns cached data when offline`() = runTest { + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { networkStateProvider.isOnline() } returns false + coEvery { localDataSource.getAssignment(assignmentId) } returns testAssignment - coEvery { oAuthInterface.getAuthenticatedSession(originalUrl, any()) } returns - DataResult.Success(session) + val result = getRepository().getAssignment(courseId, assignmentId, false) - val result = getRepository().authenticateUrl(originalUrl) - - assertEquals(authenticatedUrl, result) - } - - @Test - fun `Test URL authentication fallback on failure`() = runTest { - val originalUrl = "https://example.com/file" - coEvery { oAuthInterface.getAuthenticatedSession(originalUrl, any()) } returns DataResult.Fail() - - val result = getRepository().authenticateUrl(originalUrl) - - assertEquals(originalUrl, result) - } - - @Test - fun `Test URL authentication fallback on null session`() = runTest { - val originalUrl = "https://example.com/file" - coEvery { oAuthInterface.getAuthenticatedSession(originalUrl, any()) } returns DataResult.Fail() - - val result = getRepository().authenticateUrl(originalUrl) - - assertEquals(originalUrl, result) + assertEquals(testAssignment, result) + coVerify { localDataSource.getAssignment(assignmentId) } } @Test - fun `Test has unread comments returns true when count greater than zero`() = runTest { - coEvery { horizonGetCommentsManager.getUnreadCommentsCount(assignmentId, userId, false) } returns 3 + fun `getAssignment saves to local when online and sync enabled`() = runTest { + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { networkStateProvider.isOnline() } returns true + coEvery { networkDataSource.getAssignment(courseId, assignmentId, false) } returns testAssignment + coEvery { htmlParser.createHtmlStringWithLocalFiles(any(), any()) } returns HtmlParsingResult("parsed", emptySet(), emptySet(), emptySet()) - val result = getRepository().hasUnreadComments(assignmentId, false) + getRepository().getAssignment(courseId, assignmentId, false) - assertTrue(result) + coVerify { localDataSource.saveAssignment(testAssignment, courseId, "parsed") } } @Test - fun `Test has unread comments returns false when count is zero`() = runTest { - coEvery { horizonGetCommentsManager.getUnreadCommentsCount(assignmentId, userId, false) } returns 0 - - val result = getRepository().hasUnreadComments(assignmentId, false) - - assertFalse(result) + fun `getAssignment saves submission history when online and sync enabled`() = runTest { + val submission = Submission(id = 1L, attempt = 1L, workflowState = "submitted") + val assignmentWithSubmission = testAssignment.copy( + submission = Submission(submissionHistory = listOf(submission)) + ) + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { networkStateProvider.isOnline() } returns true + coEvery { networkDataSource.getAssignment(courseId, assignmentId, false) } returns assignmentWithSubmission + coEvery { htmlParser.createHtmlStringWithLocalFiles(any(), any()) } returns HtmlParsingResult("", emptySet(), emptySet(), emptySet()) + + getRepository().getAssignment(courseId, assignmentId, false) + + coVerify { submissionLocalDataSource.saveSubmissions(assignmentId, listOf(submission)) } } @Test - fun `Test force network parameter is passed correctly`() = runTest { - val assignment = Assignment(id = assignmentId, name = "Test Assignment") - coEvery { assignmentApi.getAssignmentWithHistory(courseId, assignmentId, any()) } returns - DataResult.Success(assignment) + fun `getAssignment passes forceRefresh to network data source`() = runTest { + coEvery { networkDataSource.getAssignment(courseId, assignmentId, true) } returns testAssignment + coEvery { htmlParser.createHtmlStringWithLocalFiles(any(), any()) } returns HtmlParsingResult("", emptySet(), emptySet(), emptySet()) - getRepository().getAssignment(assignmentId, courseId, true) + getRepository().getAssignment(courseId, assignmentId, true) - coEvery { assignmentApi.getAssignmentWithHistory(courseId, assignmentId, match { it.isForceReadFromNetwork }) } + coVerify { networkDataSource.getAssignment(courseId, assignmentId, true) } } - private fun getRepository(): AssignmentDetailsRepository { - return AssignmentDetailsRepository(assignmentApi, oAuthInterface, horizonGetCommentsManager, apiPrefs) - } + private fun getRepository() = AssignmentDetailsRepository( + networkDataSource, + localDataSource, + submissionLocalDataSource, + htmlParser, + fileSyncRepository, + networkStateProvider, + featureFlagProvider, + ) } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModelTest.kt index a9cdd1eb34..1d0daf55fe 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModelTest.kt @@ -18,8 +18,13 @@ package com.instructure.horizon.features.moduleitemsequence.content.assignment import android.content.Context import androidx.lifecycle.SavedStateHandle +import com.instructure.canvasapi2.apis.OAuthAPI import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Submission +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.horizon.domain.usecase.GetAssignmentDetailsUseCase +import com.instructure.horizon.domain.usecase.GetSubmissionHistoryUseCase +import com.instructure.horizon.domain.usecase.GetUnreadCommentsCountUseCase import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider import com.instructure.horizon.features.moduleitemsequence.ModuleItemContent import com.instructure.pandautils.utils.Const @@ -45,8 +50,12 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class AssignmentDetailsViewModelTest { private val context: Context = mockk(relaxed = true) - private val repository: AssignmentDetailsRepository = mockk(relaxed = true) + private val getAssignmentDetailsUseCase: GetAssignmentDetailsUseCase = mockk(relaxed = true) + private val getSubmissionHistoryUseCase: GetSubmissionHistoryUseCase = mockk(relaxed = true) + private val getUnreadCommentsCountUseCase: GetUnreadCommentsCountUseCase = mockk(relaxed = true) private val htmlContentFormatter: HtmlContentFormatter = mockk(relaxed = true) + private val oAuthApi: OAuthAPI.OAuthInterface = mockk(relaxed = true) + private val apiPrefs: ApiPrefs = mockk(relaxed = true) private val aiAssistContextProvider: AiAssistContextProvider = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() @@ -64,9 +73,9 @@ class AssignmentDetailsViewModelTest { @Before fun setup() { Dispatchers.setMain(testDispatcher) - coEvery { repository.getAssignment(any(), any(), any()) } returns testAssignment - coEvery { repository.hasUnreadComments(any(), any()) } returns false - coEvery { repository.authenticateUrl(any()) } returns "https://authenticated.url" + coEvery { getAssignmentDetailsUseCase(any()) } returns testAssignment + coEvery { getSubmissionHistoryUseCase(any()) } returns emptyList() + coEvery { getUnreadCommentsCountUseCase(any()) } returns 0 coEvery { htmlContentFormatter.formatHtmlWithIframes(any(), any()) } returns "Formatted content" coEvery { aiAssistContextProvider.aiAssistContext } returns mockk(relaxed = true) coEvery { aiAssistContextProvider.aiAssistContext = any() } returns Unit @@ -88,7 +97,7 @@ class AssignmentDetailsViewModelTest { val viewModel = getViewModel(savedStateHandle) assertFalse(viewModel.uiState.value.loadingState.isLoading) - coVerify { repository.getAssignment(assignmentId, courseId, false) } + coVerify { getAssignmentDetailsUseCase(GetAssignmentDetailsUseCase.Params(courseId, assignmentId, false)) } } @Test @@ -106,7 +115,7 @@ class AssignmentDetailsViewModelTest { @Test fun `Test failed data load sets error state`() = runTest { - coEvery { repository.getAssignment(any(), any(), any()) } throws Exception("Network error") + coEvery { getAssignmentDetailsUseCase(any()) } throws Exception("Network error") val savedStateHandle = SavedStateHandle(mapOf( ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, @@ -127,12 +136,12 @@ class AssignmentDetailsViewModelTest { val viewModel = getViewModel(savedStateHandle) - coVerify { repository.hasUnreadComments(assignmentId, false) } + coVerify { getUnreadCommentsCountUseCase(any()) } } @Test fun `Test unread comments flag is set correctly`() = runTest { - coEvery { repository.hasUnreadComments(any(), any()) } returns true + coEvery { getUnreadCommentsCountUseCase(any()) } returns 1 val savedStateHandle = SavedStateHandle(mapOf( ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, @@ -147,7 +156,7 @@ class AssignmentDetailsViewModelTest { @Test fun `Test assignment with no submission shows add submission`() = runTest { val assignmentWithoutSubmission = testAssignment.copy(submission = null) - coEvery { repository.getAssignment(any(), any(), any()) } returns assignmentWithoutSubmission + coEvery { getAssignmentDetailsUseCase(any()) } returns assignmentWithoutSubmission val savedStateHandle = SavedStateHandle(mapOf( ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, @@ -166,7 +175,7 @@ class AssignmentDetailsViewModelTest { val assignmentWithSubmission = testAssignment.copy( submission = submission ) - coEvery { repository.getAssignment(any(), any(), any()) } returns assignmentWithSubmission + coEvery { getAssignmentDetailsUseCase(any()) } returns assignmentWithSubmission val savedStateHandle = SavedStateHandle(mapOf( ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, @@ -178,26 +187,6 @@ class AssignmentDetailsViewModelTest { assertTrue(viewModel.uiState.value.showSubmissionDetails) } - @Test - fun `Test LTI URL authentication is performed`() = runTest { - val ltiUrl = "https://lti.example.com/launch" - val testAssignmentWithLti = testAssignment.copy( - externalToolAttributes = mockk { - coEvery { url } returns ltiUrl - } - ) - coEvery { repository.getAssignment(any(), any(), any()) } returns testAssignmentWithLti - - val savedStateHandle = SavedStateHandle(mapOf( - ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, - Const.COURSE_ID to courseId - )) - - val viewModel = getViewModel(savedStateHandle) - - assertEquals(ltiUrl, viewModel.uiState.value.ltiUrl) - } - @Test fun `Test HTML content formatting is applied to description`() = runTest { val savedStateHandle = SavedStateHandle(mapOf( @@ -214,7 +203,7 @@ class AssignmentDetailsViewModelTest { @Test fun `Test attempt selector visibility for multiple attempts`() = runTest { val assignmentWithMultipleAttempts = testAssignment.copy(allowedAttempts = 3L) - coEvery { repository.getAssignment(any(), any(), any()) } returns assignmentWithMultipleAttempts + coEvery { getAssignmentDetailsUseCase(any()) } returns assignmentWithMultipleAttempts val savedStateHandle = SavedStateHandle(mapOf( ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, @@ -229,7 +218,7 @@ class AssignmentDetailsViewModelTest { @Test fun `Test attempt selector is hidden for single attempt assignment`() = runTest { val assignmentWithSingleAttempt = testAssignment.copy(allowedAttempts = 1L) - coEvery { repository.getAssignment(any(), any(), any()) } returns assignmentWithSingleAttempt + coEvery { getAssignmentDetailsUseCase(any()) } returns assignmentWithSingleAttempt val savedStateHandle = SavedStateHandle(mapOf( ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, @@ -258,10 +247,14 @@ class AssignmentDetailsViewModelTest { private fun getViewModel(savedStateHandle: SavedStateHandle): AssignmentDetailsViewModel { return AssignmentDetailsViewModel( context, - repository, + getAssignmentDetailsUseCase, + getSubmissionHistoryUseCase, + getUnreadCommentsCountUseCase, htmlContentFormatter, + oAuthApi, + apiPrefs, aiAssistContextProvider, savedStateHandle ) } -} +} \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepositoryTest.kt index bbb8147381..470e9c86fb 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepositoryTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepositoryTest.kt @@ -23,6 +23,9 @@ import com.instructure.canvasapi2.managers.graphql.horizon.CommentsData import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager import com.instructure.canvasapi2.models.Submission import com.instructure.canvasapi2.utils.DataResult +import com.instructure.horizon.data.datasource.AssignmentCommentsLocalDataSource +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -38,6 +41,9 @@ import java.util.Date class CommentsRepositoryTest { private val getCommentsManager: HorizonGetCommentsManager = mockk(relaxed = true) private val submissionApi: SubmissionAPI.SubmissionInterface = mockk(relaxed = true) + private val localDataSource: AssignmentCommentsLocalDataSource = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) private lateinit var repository: CommentsRepository @@ -66,7 +72,7 @@ class CommentsRepositoryTest { @Before fun setup() { - repository = CommentsRepository(getCommentsManager, submissionApi) + repository = CommentsRepository(getCommentsManager, submissionApi, localDataSource, networkStateProvider, featureFlagProvider) } @After From 63e32e21d53f29fb7f211fb94c5081023cab30a4 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 15 Apr 2026 12:45:01 +0200 Subject: [PATCH 26/31] Fix tests --- .../espresso/HorizonOfflineTestModule.kt | 16 ++ .../assessment/AssessmentViewModelTest.kt | 28 ++- .../AssignmentDetailsViewModelTest.kt | 5 +- .../content/page/PageDetailsRepositoryTest.kt | 209 ------------------ .../content/page/PageDetailsViewModelTest.kt | 4 +- 5 files changed, 38 insertions(+), 224 deletions(-) delete mode 100644 libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsRepositoryTest.kt diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt index 37d71c03a2..c3eaa98f96 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt @@ -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 @@ -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 @@ -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( diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModelTest.kt index 2ededc5f96..cee00f928d 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModelTest.kt @@ -17,7 +17,13 @@ package com.instructure.horizon.features.moduleitemsequence.content.assessment import androidx.lifecycle.SavedStateHandle +import com.instructure.canvasapi2.apis.LaunchDefinitionsAPI +import com.instructure.canvasapi2.apis.OAuthAPI import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.AuthenticatedSession +import com.instructure.canvasapi2.models.LTITool +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.horizon.domain.usecase.GetAssignmentDetailsUseCase import com.instructure.horizon.features.moduleitemsequence.ModuleItemContent import com.instructure.pandautils.utils.Const import io.mockk.coEvery @@ -43,7 +49,9 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class AssessmentViewModelTest { - private val repository: AssessmentRepository = mockk(relaxed = true) + private val getAssignmentDetailsUseCase: GetAssignmentDetailsUseCase = mockk(relaxed = true) + private val launchDefinitionsApi: LaunchDefinitionsAPI.LaunchDefinitionsInterface = mockk(relaxed = true) + private val oAuthApi: OAuthAPI.OAuthInterface = mockk(relaxed = true) private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() @@ -60,8 +68,11 @@ class AssessmentViewModelTest { Dispatchers.setMain(testDispatcher) every { savedStateHandle.get(ModuleItemContent.Assignment.ASSIGNMENT_ID) } returns assignmentId every { savedStateHandle.get(Const.COURSE_ID) } returns courseId - coEvery { repository.getAssignment(any(), any(), any()) } returns testAssignment - coEvery { repository.authenticateUrl(any()) } returns "https://authenticated.url" + coEvery { getAssignmentDetailsUseCase(any()) } returns testAssignment + coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } returns + DataResult.Success(LTITool(url = "https://lti.url")) + coEvery { oAuthApi.getAuthenticatedSession(any(), any()) } returns + DataResult.Success(AuthenticatedSession(sessionUrl = "https://authenticated.url")) } @After @@ -76,7 +87,7 @@ class AssessmentViewModelTest { assertFalse(viewModel.uiState.value.loadingState.isLoading) assertEquals("Test Quiz", viewModel.uiState.value.assessmentName) - coVerify { repository.getAssignment(assignmentId, courseId, false) } + coVerify { getAssignmentDetailsUseCase(GetAssignmentDetailsUseCase.Params(courseId, assignmentId)) } } @Test @@ -89,12 +100,11 @@ class AssessmentViewModelTest { assertEquals("https://authenticated.url", viewModel.uiState.value.urlToLoad) viewModel.uiState.value.onAssessmentLoaded() assertFalse(viewModel.uiState.value.assessmentLoading) - coVerify { repository.authenticateUrl("https://example.com/quiz/1") } } @Test fun `Test start quiz with authentication error`() = runTest { - coEvery { repository.authenticateUrl(any()) } throws Exception("Auth error") + coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } throws Exception("Auth error") val viewModel = getViewModel() @@ -148,7 +158,7 @@ class AssessmentViewModelTest { @Test fun `Test load error sets error state`() = runTest { - coEvery { repository.getAssignment(any(), any(), any()) } throws Exception("Error") + coEvery { getAssignmentDetailsUseCase(any()) } throws Exception("Error") val viewModel = getViewModel() @@ -157,7 +167,7 @@ class AssessmentViewModelTest { @Test fun `Test start quiz with null assessment URL`() = runTest { - coEvery { repository.getAssignment(any(), any(), any()) } returns testAssignment.copy(url = null) + coEvery { getAssignmentDetailsUseCase(any()) } returns testAssignment.copy(url = null) val viewModel = getViewModel() @@ -179,6 +189,6 @@ class AssessmentViewModelTest { } private fun getViewModel(): AssessmentViewModel { - return AssessmentViewModel(repository, savedStateHandle) + return AssessmentViewModel(getAssignmentDetailsUseCase, launchDefinitionsApi, oAuthApi, savedStateHandle) } } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModelTest.kt index 1d0daf55fe..bba5d3bc82 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModelTest.kt @@ -172,10 +172,7 @@ class AssignmentDetailsViewModelTest { @Test fun `Test assignment with submission shows submission details`() = runTest { val submission = Submission(attempt = 1L, workflowState = "submitted") - val assignmentWithSubmission = testAssignment.copy( - submission = submission - ) - coEvery { getAssignmentDetailsUseCase(any()) } returns assignmentWithSubmission + coEvery { getSubmissionHistoryUseCase(any()) } returns listOf(submission) val savedStateHandle = SavedStateHandle(mapOf( ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsRepositoryTest.kt deleted file mode 100644 index 2c7e74f30d..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsRepositoryTest.kt +++ /dev/null @@ -1,209 +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.moduleitemsequence.content.page - -import com.instructure.canvasapi2.apis.OAuthAPI -import com.instructure.canvasapi2.apis.PageAPI -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManager -import com.instructure.canvasapi2.models.AuthenticatedSession -import com.instructure.canvasapi2.models.Page -import com.instructure.canvasapi2.utils.DataResult -import com.instructure.redwood.QueryNotesQuery -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import io.mockk.unmockkAll -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Test -import java.util.Date - -class PageDetailsRepositoryTest { - private val pageApi: PageAPI.PagesInterface = mockk(relaxed = true) - private val oAuthInterface: OAuthAPI.OAuthInterface = mockk(relaxed = true) - private val redwoodApi: RedwoodApiManager = mockk(relaxed = true) - - private lateinit var repository: PageDetailsRepository - - private val testPage = Page( - id = 1L, - url = "test-page", - title = "Test Page", - body = "

Page content

" - ) - - private val testNotes = QueryNotesQuery.Notes( - pageInfo = QueryNotesQuery.PageInfo( - hasNextPage = false, - hasPreviousPage = false, - startCursor = null, - endCursor = null - ), - edges = listOf( - QueryNotesQuery.Edge( - cursor = "", - node = QueryNotesQuery.Node( - id = "1", - objectId = "1", - objectType = "Page", - userText = "comment 1", - rootAccountUuid = "1", - userId = "1", - courseId = "1", - reaction = listOf("Important"), - highlightData = "", - createdAt = Date(), - updatedAt = Date(), - ) - ), - QueryNotesQuery.Edge( - cursor = "", - node = QueryNotesQuery.Node( - id = "2", - objectId = "1", - objectType = "Page", - userText = "comment 2", - rootAccountUuid = "1", - userId = "1", - courseId = "1", - reaction = listOf("Important"), - highlightData = "", - createdAt = Date(), - updatedAt = Date(), - ) - ) - ) - ) - - @Before - fun setup() { - repository = PageDetailsRepository(pageApi, oAuthInterface, redwoodApi) - } - - @After - fun tearDown() { - unmockkAll() - } - - @Test - fun `getPageDetails returns page successfully`() = runTest { - coEvery { pageApi.getDetailedPage(any(), any(), any(), any()) } returns DataResult.Success(testPage) - - val result = repository.getPageDetails(courseId = 1L, pageId = "test-page", forceNetwork = false) - - assertEquals("Test Page", result.title) - assertEquals("

Page content

", result.body) - coVerify { pageApi.getDetailedPage("courses", 1L, "test-page", any()) } - } - - @Test - fun `getPageDetails with forceNetwork true`() = runTest { - coEvery { pageApi.getDetailedPage(any(), any(), any(), any()) } returns DataResult.Success(testPage) - - repository.getPageDetails(courseId = 1L, pageId = "test-page", forceNetwork = true) - - coVerify { pageApi.getDetailedPage(any(), any(), any(), match { it.isForceReadFromNetwork }) } - } - - @Test - fun `getPageDetails with forceNetwork false`() = runTest { - coEvery { pageApi.getDetailedPage(any(), any(), any(), any()) } returns DataResult.Success(testPage) - - repository.getPageDetails(courseId = 1L, pageId = "test-page", forceNetwork = false) - - coVerify { pageApi.getDetailedPage(any(), any(), any(), match { !it.isForceReadFromNetwork }) } - } - - @Test - fun `authenticateUrl returns authenticated URL`() = runTest { - val session = AuthenticatedSession(sessionUrl = "https://authenticated.url") - coEvery { oAuthInterface.getAuthenticatedSession(any(), any()) } returns DataResult.Success(session) - - val result = repository.authenticateUrl("https://example.com/page") - - assertEquals("https://authenticated.url", result) - coVerify { oAuthInterface.getAuthenticatedSession("https://example.com/page", any()) } - } - - @Test - fun `authenticateUrl returns original URL on failure`() = runTest { - coEvery { oAuthInterface.getAuthenticatedSession(any(), any()) } returns DataResult.Fail() - - val result = repository.authenticateUrl("https://example.com/page") - - assertEquals("https://example.com/page", result) - } - - @Test - fun `authenticateUrl returns original URL when session URL is null`() = runTest { - val session = AuthenticatedSession(sessionUrl = "https://example.com/page/authenticated") - coEvery { oAuthInterface.getAuthenticatedSession(any(), any()) } returns DataResult.Success(session) - - val result = repository.authenticateUrl("https://example.com/page") - - assertEquals("https://example.com/page/authenticated", result) - } - - @Test - fun `getNotes returns notes list`() = runTest { - coEvery { redwoodApi.getNotes(any(), any(), any()) } returns testNotes - - val result = repository.getNotes(courseId = 1L, pageId = 100L) - - assertEquals(2, result.size) - assertEquals("comment 1", result.first().userText) - coVerify { redwoodApi.getNotes(any(), null, null) } - } - - @Test - fun `getNotes with different page ID`() = runTest { - coEvery { redwoodApi.getNotes(any(), any(), any()) } returns testNotes - - repository.getNotes(courseId = 5L, pageId = 200L) - - coVerify { redwoodApi.getNotes(any(), null, null) } - } - - @Test - fun `getNotes returns empty list`() = runTest { - coEvery { redwoodApi.getNotes(any(), any(), any()) } returns QueryNotesQuery.Notes( - pageInfo = QueryNotesQuery.PageInfo( - hasNextPage = false, - hasPreviousPage = false, - startCursor = null, - endCursor = null - ), - edges = emptyList() - ) - - val result = repository.getNotes(courseId = 1L, pageId = 100L) - - assertEquals(0, result.size) - } - - @Test - fun `authenticateUrl always uses forceNetwork`() = runTest { - val session = AuthenticatedSession(sessionUrl = "https://authenticated.url") - coEvery { oAuthInterface.getAuthenticatedSession(any(), any()) } returns DataResult.Success(session) - - repository.authenticateUrl("https://example.com") - - coVerify { oAuthInterface.getAuthenticatedSession(any(), match { it.isForceReadFromNetwork }) } - } -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt index 05285e827f..f8f835e3c8 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt @@ -20,8 +20,8 @@ import androidx.lifecycle.SavedStateHandle import com.instructure.canvasapi2.apis.OAuthAPI import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManager import com.instructure.canvasapi2.models.AuthenticatedSession -import com.instructure.canvasapi2.models.DataResult import com.instructure.canvasapi2.models.Page +import com.instructure.canvasapi2.utils.DataResult import com.instructure.horizon.domain.usecase.GetPageDetailsUseCase import com.instructure.horizon.features.moduleitemsequence.ModuleItemContent import com.instructure.horizon.features.notebook.addedit.add.AddNoteRepository @@ -79,7 +79,7 @@ class PageDetailsViewModelTest { objectId = testPage.id.toString(), objectType = "Page", reaction = listOf("Important"), - highlightData = null + highlightData = "" ) private val testNotesResponse = QueryNotesQuery.Notes( From 57f788aa1b6172d01e4fd4b427cd8f36b32fbf44 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 15 Apr 2026 13:18:17 +0200 Subject: [PATCH 27/31] Fix feature flag --- .../com/instructure/pandautils/utils/FeatureFlagProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt index 8d493122ff..14df17cf46 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt @@ -53,7 +53,7 @@ class FeatureFlagProvider( } suspend fun offlineEnabled(): Boolean { - return true//checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary + return checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary } private suspend fun checkEnvironmentFeatureFlag(featureFlag: String): Boolean { From 3a4dce19a5b4c9da75b98c21b4a44831e3bb62a4 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 22 Apr 2026 12:40:06 +0200 Subject: [PATCH 28/31] Fix merge issues --- .../horizon/features/dashboard/DashboardViewModel.kt | 1 + .../horizon/features/learn/LearnViewModel.kt | 4 +++- .../learn/course/details/CourseDetailsViewModel.kt | 4 +++- .../details/progress/CourseProgressViewModel.kt | 6 ++++-- .../course/details/score/CourseScoreViewModel.kt | 4 +++- .../list/LearnLearningLibraryListViewModel.kt | 4 +++- .../mycontent/common/LearnMyContentViewModel.kt | 6 ++++-- .../completed/LearnMyContentCompletedViewModel.kt | 4 +++- .../inprogress/LearnMyContentInProgressViewModel.kt | 4 +++- .../mycontent/saved/LearnMyContentSavedViewModel.kt | 4 +++- .../learn/program/details/ProgramDetailsViewModel.kt | 12 +++++++----- 11 files changed, 37 insertions(+), 16 deletions(-) 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 412551d36c..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 @@ -26,6 +26,7 @@ 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 diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt index 99183c1ac9..e39adf9daa 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt @@ -18,6 +18,7 @@ package com.instructure.horizon.features.learn import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.offline.HorizonOfflineViewModel import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.NetworkStateProvider @@ -32,7 +33,8 @@ class LearnViewModel @Inject constructor( private val repository: LearnRepository, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, -) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { private val _uiState = MutableStateFlow(LearnUiState( updateSelectedTab = ::updateSelectedTab, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModel.kt index e23502cf28..6de6944d51 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModel.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.data.repository.CourseRepository +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.features.learn.navigation.LearnRoute import com.instructure.horizon.offline.HorizonOfflineViewModel import com.instructure.pandautils.utils.FeatureFlagProvider @@ -36,7 +37,8 @@ class CourseDetailsViewModel @Inject constructor( private val repository: CourseRepository, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, -) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { private val _uiState = MutableStateFlow(CourseDetailsUiState()) val state = _uiState.asStateFlow() diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressViewModel.kt index ba2a88f0d0..965763a529 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressViewModel.kt @@ -22,9 +22,10 @@ import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.data.repository.CourseRepository +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.features.learn.LearnEvent import com.instructure.horizon.features.learn.LearnEventHandler -import com.instructure.horizon.data.repository.CourseRepository import com.instructure.horizon.horizonui.organisms.cards.ModuleHeaderStateMapper import com.instructure.horizon.horizonui.organisms.cards.ModuleItemCardStateMapper import com.instructure.horizon.horizonui.platform.LoadingState @@ -49,7 +50,8 @@ class CourseProgressViewModel @Inject constructor( private val learnEventHandler: LearnEventHandler, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, -) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { private val _uiState = MutableStateFlow( CourseProgressUiState( screenState = LoadingState( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreViewModel.kt index c5388cb9ae..92cbfce098 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreViewModel.kt @@ -24,6 +24,7 @@ import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R import com.instructure.horizon.data.repository.CourseScoreRepository +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.horizonui.platform.LoadingState import com.instructure.horizon.offline.HorizonOfflineViewModel import com.instructure.pandautils.utils.FeatureFlagProvider @@ -43,7 +44,8 @@ class CourseScoreViewModel @Inject constructor( private val courseScoreRepository: CourseScoreRepository, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, -) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { private val _uiState = MutableStateFlow( CourseScoreUiState( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModel.kt index dabc361bc6..abc8c93c11 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModel.kt @@ -27,6 +27,7 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.features.learn.LearnEvent import com.instructure.horizon.features.learn.LearnEventHandler import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryCollectionState @@ -58,7 +59,8 @@ class LearnLearningLibraryListViewModel @Inject constructor( private val apiPrefs: ApiPrefs, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, -) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { private var currentTypeFilter: LearnLearningLibraryTypeFilter = LearnLearningLibraryTypeFilter.All private var currentSortOption: LearnLearningLibrarySortOption = LearnLearningLibrarySortOption.MostRecent diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentViewModel.kt index d625e85a8c..b19b8b7e5d 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentViewModel.kt @@ -21,6 +21,7 @@ import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemS import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter @@ -42,7 +43,8 @@ abstract class LearnMyContentViewModel( protected val getNextModuleItemUseCase: GetNextModuleItemUseCase, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, -) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { + getLastSyncedAtUseCase: GetLastSyncedAtUseCase, +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { private data class Filters( val searchQuery: String = "", @@ -150,7 +152,7 @@ abstract class LearnMyContentViewModel( protected suspend fun fetchNextModuleItemRoute(courseId: Long?): Any? { if (courseId == null) return null - val nextModuleItem = getNextModuleItemUseCase(courseId) ?: return null + val nextModuleItem = getNextModuleItemUseCase(GetNextModuleItemUseCase.Params(courseId)) ?: return null return MainNavigationRoute.ModuleItemSequence(courseId, nextModuleItem.moduleItemId) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModel.kt index bad8f0ceb5..f32672255f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModel.kt @@ -20,6 +20,7 @@ import android.content.res.Resources import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.domain.usecase.GetLearnMyContentCompletedItemsParams import com.instructure.horizon.domain.usecase.GetLearnMyContentCompletedItemsUseCase import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase @@ -39,7 +40,8 @@ class LearnMyContentCompletedViewModel @Inject constructor( getNextModuleItemUseCase: GetNextModuleItemUseCase, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, -) : LearnMyContentViewModel(getNextModuleItemUseCase, networkStateProvider, featureFlagProvider) { + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : LearnMyContentViewModel(getNextModuleItemUseCase, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { override val errorMessage: String get() = resources.getString(R.string.learnMyContentProgramErrorMessage) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModel.kt index c16a8243d0..a5b615ca23 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModel.kt @@ -20,6 +20,7 @@ import android.content.res.Resources import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.domain.usecase.GetLearnMyContentInProgressItemsParams import com.instructure.horizon.domain.usecase.GetLearnMyContentInProgressItemsUseCase import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase @@ -39,7 +40,8 @@ class LearnMyContentInProgressViewModel @Inject constructor( getNextModuleItemUseCase: GetNextModuleItemUseCase, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, -) : LearnMyContentViewModel(getNextModuleItemUseCase, networkStateProvider, featureFlagProvider) { + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : LearnMyContentViewModel(getNextModuleItemUseCase, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { override val errorMessage: String get() = resources.getString(R.string.learnMyContentProgramErrorMessage) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModel.kt index b3aeff2053..bbe5f5c83a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModel.kt @@ -23,6 +23,7 @@ import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibrary import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryItemsParams import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryItemsUseCase import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryRecommendationsParams @@ -49,7 +50,8 @@ class LearnMyContentSavedViewModel @Inject constructor( getNextModuleItemUseCase: GetNextModuleItemUseCase, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, -) : LearnMyContentViewModel(getNextModuleItemUseCase, networkStateProvider, featureFlagProvider) { + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : LearnMyContentViewModel(getNextModuleItemUseCase, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { override val errorMessage: String get() = resources.getString(R.string.learnMyContentProgramErrorMessage) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModel.kt index a7aafa375b..94f7849620 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModel.kt @@ -25,6 +25,8 @@ import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequir import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.data.repository.ProgramRepository +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.features.dashboard.DashboardEvent import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.horizon.features.learn.navigation.LearnRoute @@ -37,16 +39,15 @@ import com.instructure.horizon.features.learn.program.details.components.Program import com.instructure.horizon.features.learn.program.details.components.SequentialProgramProgressProperties import com.instructure.horizon.horizonui.molecules.StatusChipColor import com.instructure.horizon.horizonui.platform.LoadingState +import com.instructure.horizon.offline.HorizonOfflineViewModel import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus import com.instructure.journey.type.ProgramVariantType +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.formatMonthDayYear import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.sum import com.instructure.pandautils.utils.toFormattedString -import com.instructure.horizon.data.repository.ProgramRepository -import com.instructure.horizon.offline.HorizonOfflineViewModel -import com.instructure.pandautils.utils.FeatureFlagProvider -import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow @@ -65,7 +66,8 @@ class ProgramDetailsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, -) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider) { + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { private val programId = savedStateHandle.get(LearnRoute.LearnProgramDetailsScreen.programIdAttr) ?: "" From 8b723ebb0dfaf964006639e26cca03f43bb78aed Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 22 Apr 2026 13:34:48 +0200 Subject: [PATCH 29/31] Fix tests --- .../horizon/features/dashboard/DashboardViewModelTest.kt | 3 --- .../learn/course/details/CourseDetailsViewModelTest.kt | 6 ++++-- .../list/LearnLearningLibraryListViewModelTest.kt | 4 +++- .../completed/LearnMyContentCompletedViewModelTest.kt | 4 +++- .../inprogress/LearnMyContentInProgressViewModelTest.kt | 5 +++-- .../mycontent/saved/LearnMyContentSavedViewModelTest.kt | 4 +++- .../learn/program/details/ProgramDetailsViewModelTest.kt | 4 +++- 7 files changed, 19 insertions(+), 11 deletions(-) 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 9ab86ce1c8..d44f808e14 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/DashboardViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/DashboardViewModelTest.kt @@ -46,9 +46,6 @@ 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 networkStateProvider: NetworkStateProvider = mockk(relaxed = true) private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModelTest.kt index 1acb233efa..48a40c9700 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModelTest.kt @@ -21,6 +21,7 @@ import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement import com.instructure.horizon.data.repository.CourseRepository +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.features.learn.navigation.LearnRoute import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.NetworkStateProvider @@ -46,6 +47,7 @@ class CourseDetailsViewModelTest { private val repository: CourseRepository = mockk(relaxed = true) private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val testCourseId = 123L @@ -219,7 +221,7 @@ class CourseDetailsViewModelTest { @Test fun `Invalid course ID defaults to -1`() { val savedStateHandle = SavedStateHandle() - val viewModel = CourseDetailsViewModel(savedStateHandle, repository, networkStateProvider, featureFlagProvider) + val viewModel = CourseDetailsViewModel(savedStateHandle, repository, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) coVerify { repository.getCourse(-1L, any()) } } @@ -248,6 +250,6 @@ class CourseDetailsViewModelTest { val savedStateHandle = SavedStateHandle(mapOf( LearnRoute.LearnCourseDetailsScreen.courseIdAttr to courseId )) - return CourseDetailsViewModel(savedStateHandle, repository, networkStateProvider, featureFlagProvider) + return CourseDetailsViewModel(savedStateHandle, repository, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) } } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt index f098df430f..ba068c995c 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt @@ -27,6 +27,7 @@ import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibrary import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.features.learn.LearnEvent import com.instructure.horizon.features.learn.LearnEventHandler import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryFilterScreenType @@ -64,6 +65,7 @@ class LearnLearningLibraryListViewModelTest { private val apiPrefs: ApiPrefs = mockk(relaxed = true) private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val emptyItemsResponse = LearningLibraryCollectionItemsResponse( @@ -802,7 +804,7 @@ class LearnLearningLibraryListViewModelTest { } private fun getViewModel(): LearnLearningLibraryListViewModel { - return LearnLearningLibraryListViewModel(resources, repository, eventHandler, apiPrefs, networkStateProvider, featureFlagProvider) + return LearnLearningLibraryListViewModel(resources, repository, eventHandler, apiPrefs, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) } private fun createTestCollection( diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt index d56848aa17..96fac01b3a 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt @@ -22,6 +22,7 @@ import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibrary import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse import com.instructure.canvasapi2.models.journey.mycontent.ProgramEnrollmentItem +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.domain.usecase.GetLearnMyContentCompletedItemsUseCase import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption @@ -55,6 +56,7 @@ class LearnMyContentCompletedViewModelTest { private val getNextModuleItemUseCase: GetNextModuleItemUseCase = mockk(relaxed = true) private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val emptyResponse = LearnItemsResponse( @@ -206,7 +208,7 @@ class LearnMyContentCompletedViewModelTest { } private fun getViewModel() = LearnMyContentCompletedViewModel( - resources, getLearnMyContentCompletedItemsUseCase, getNextModuleItemUseCase, networkStateProvider, featureFlagProvider + resources, getLearnMyContentCompletedItemsUseCase, getNextModuleItemUseCase, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase ) private fun createTestProgramItem( diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt index c775bd96f9..81a36e2c26 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt @@ -23,7 +23,7 @@ import com.instructure.canvasapi2.models.journey.mycontent.CourseEnrollmentItem import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse import com.instructure.canvasapi2.models.journey.mycontent.ProgramEnrollmentItem -import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.domain.usecase.GetLearnMyContentInProgressItemsUseCase import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption @@ -59,6 +59,7 @@ class LearnMyContentInProgressViewModelTest { private val getNextModuleItemUseCase: GetNextModuleItemUseCase = mockk(relaxed = true) private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val emptyResponse = LearnItemsResponse( @@ -388,7 +389,7 @@ class LearnMyContentInProgressViewModelTest { } private fun getViewModel() = LearnMyContentInProgressViewModel( - resources, getLearnMyContentInProgressItemsUseCase, getNextModuleItemUseCase, networkStateProvider, featureFlagProvider + resources, getLearnMyContentInProgressItemsUseCase, getNextModuleItemUseCase, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase ) private fun createTestProgramItem( diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt index b9db598f60..ec6332b05b 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt @@ -24,6 +24,7 @@ import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibrary import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryItemsUseCase import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryRecommendationsUseCase import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase @@ -62,6 +63,7 @@ class LearnMyContentSavedViewModelTest { private val getNextModuleItemUseCase: GetNextModuleItemUseCase = mockk(relaxed = true) private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val emptyResponse = LearningLibraryCollectionItemsResponse( @@ -311,7 +313,7 @@ class LearnMyContentSavedViewModelTest { private fun getViewModel() = LearnMyContentSavedViewModel( resources, getLearnLearningLibraryItemsUseCase, getLearnLearningLibraryRecommendationsUseCase, - toggleLearnLearningLibraryItemBookmarkUseCase, getNextModuleItemUseCase, networkStateProvider, featureFlagProvider + toggleLearnLearningLibraryItemBookmarkUseCase, getNextModuleItemUseCase, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase ) private fun createTestCollectionItem( diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModelTest.kt index fc9864ea95..6fd28a18cb 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModelTest.kt @@ -26,6 +26,7 @@ import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.canvasapi2.utils.DataResult import com.instructure.horizon.R import com.instructure.horizon.data.repository.ProgramRepository +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.horizon.features.learn.navigation.LearnRoute import com.instructure.horizon.features.learn.program.details.components.CourseCardStatus @@ -61,6 +62,7 @@ class ProgramDetailsViewModelTest { private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val testProgramId = "program123" @@ -656,7 +658,7 @@ class ProgramDetailsViewModelTest { } private fun getViewModel(): ProgramDetailsViewModel { - val viewModel = ProgramDetailsViewModel(context, resources, repository, dashboardEventHandler, savedStateHandle, networkStateProvider, featureFlagProvider) + val viewModel = ProgramDetailsViewModel(context, resources, repository, dashboardEventHandler, savedStateHandle, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) testDispatcher.scheduler.advanceUntilIdle() return viewModel } From 166eb6e74aa5038d1b0a9bc42209934a9e8dd9c2 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 22 Apr 2026 14:33:38 +0200 Subject: [PATCH 30/31] Fix tests --- .../content/assessment/AssessmentViewModelTest.kt | 2 +- .../content/page/PageDetailsViewModelTest.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModelTest.kt index cee00f928d..75213e27ef 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModelTest.kt @@ -71,7 +71,7 @@ class AssessmentViewModelTest { coEvery { getAssignmentDetailsUseCase(any()) } returns testAssignment coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } returns DataResult.Success(LTITool(url = "https://lti.url")) - coEvery { oAuthApi.getAuthenticatedSession(any(), any()) } returns + coEvery { oAuthApi.getAuthenticatedSession(any(), any(), any()) } returns DataResult.Success(AuthenticatedSession(sessionUrl = "https://authenticated.url")) } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt index f8f835e3c8..db52cb8d0a 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt @@ -99,7 +99,7 @@ class PageDetailsViewModelTest { every { savedStateHandle.get(ModuleItemContent.Page.PAGE_URL) } returns pageUrl coEvery { getPageDetailsUseCase(any()) } returns testPage coEvery { redwoodApi.getNotes(any(), any(), any(), any(), any(), any(), any()) } returns testNotesResponse - coEvery { oAuthApi.getAuthenticatedSession(any(), any()) } returns + coEvery { oAuthApi.getAuthenticatedSession(any(), any(), any()) } returns DataResult.Success(AuthenticatedSession(sessionUrl = "https://authenticated.url")) coEvery { htmlContentFormatter.formatHtmlWithIframes(any(), any()) } answers { firstArg() } coEvery { addNoteRepository.addNote(any(), any(), any(), any(), any(), any()) } returns Unit @@ -159,12 +159,12 @@ class PageDetailsViewModelTest { viewModel.uiState.value.ltiButtonPressed?.invoke("https://lti.url") assertEquals("https://authenticated.url", viewModel.uiState.value.urlToOpen) - coVerify { oAuthApi.getAuthenticatedSession("https://lti.url", any()) } + coVerify { oAuthApi.getAuthenticatedSession("https://lti.url", any(), any()) } } @Test fun `Test LTI authentication failure returns original URL`() = runTest { - coEvery { oAuthApi.getAuthenticatedSession(any(), any()) } throws Exception("Auth error") + coEvery { oAuthApi.getAuthenticatedSession(any(), any(), any()) } throws Exception("Auth error") val viewModel = getViewModel() From 983d11d455f296cb79febeb3970075d2765bd2e8 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Thu, 23 Apr 2026 09:52:05 +0200 Subject: [PATCH 31/31] Merge fixes --- .../com/instructure/horizon/di/HorizonOfflineModule.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt index 1662ade86b..09de327f8b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt @@ -19,12 +19,8 @@ import android.content.Context import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.horizon.database.HorizonDatabase import com.instructure.horizon.database.HorizonDatabaseProvider -import com.instructure.horizon.offline.HorizonHtmlParserFileSource -import com.instructure.pandautils.features.offline.sync.HtmlParser -import dagger.hilt.android.qualifiers.ApplicationContext import com.instructure.horizon.database.dao.HorizonAssignmentCommentDao import com.instructure.horizon.database.dao.HorizonAssignmentDetailsDao -import com.instructure.horizon.database.dao.HorizonSubmissionDao import com.instructure.horizon.database.dao.HorizonCourseDao import com.instructure.horizon.database.dao.HorizonCourseModuleDao import com.instructure.horizon.database.dao.HorizonCourseScoreDao @@ -37,10 +33,14 @@ 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.offline.HorizonHtmlParserFileSource +import com.instructure.pandautils.features.offline.sync.HtmlParser import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent @Module