diff --git a/scripts/langindex.json b/scripts/langindex.json index 56faabdba..d0ecb48f7 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1559,6 +1559,7 @@ "core.course.highlighted": "moodle", "core.course.insufficientavailablequota": "local_moodlemobileapp", "core.course.insufficientavailablespace": "local_moodlemobileapp", + "core.course.lastaccessedactivity": "local_moodlemobileapp", "core.course.manualcompletionnotsynced": "local_moodlemobileapp", "core.course.modulenotfound": "local_moodlemobileapp", "core.course.nocontentavailable": "local_moodlemobileapp", diff --git a/src/addons/block/recentlyaccesseditems/services/recentlyaccesseditems.ts b/src/addons/block/recentlyaccesseditems/services/recentlyaccesseditems.ts index 8261f24d3..eaafaebec 100644 --- a/src/addons/block/recentlyaccesseditems/services/recentlyaccesseditems.ts +++ b/src/addons/block/recentlyaccesseditems/services/recentlyaccesseditems.ts @@ -49,17 +49,41 @@ export class AddonBlockRecentlyAccessedItemsProvider { cacheKey: this.getRecentItemsCacheKey(), }; - const items: AddonBlockRecentlyAccessedItemsItem[] = + let items: AddonBlockRecentlyAccessedItemsItem[] = await site.read('block_recentlyaccesseditems_get_recent_items', undefined, preSets); - return await Promise.all(items.map(async (item) => { + const cmIds: number[] = []; + + items = await Promise.all(items.map(async (item) => { const modicon = item.icon && CoreDomUtils.getHTMLElementAttribute(item.icon, 'src'); item.iconUrl = await CoreCourse.getModuleIconSrc(item.modname, modicon || undefined); item.iconTitle = item.icon && CoreDomUtils.getHTMLElementAttribute(item.icon, 'title'); + cmIds.push(item.cmid); return item; })); + + // Check if the viewed module should be updated for each activity. + const lastViewedMap = await CoreCourse.getCertainModulesViewed(cmIds, site.getId()); + + items.forEach((recentItem) => { + const timeAccess = recentItem.timeaccess * 1000; + const lastViewed = lastViewedMap[recentItem.cmid]; + + if (lastViewed && lastViewed.timeaccess >= timeAccess) { + return; // No need to update. + } + + // Update access. + CoreCourse.storeModuleViewed(recentItem.courseid, recentItem.cmid, { + timeaccess: recentItem.timeaccess * 1000, + sectionId: lastViewed && lastViewed.sectionId, + siteId: site.getId(), + }); + }); + + return items; } /** diff --git a/src/addons/mod/book/services/book.ts b/src/addons/mod/book/services/book.ts index 16cdd0360..2c62c96b9 100644 --- a/src/addons/mod/book/services/book.ts +++ b/src/addons/mod/book/services/book.ts @@ -391,7 +391,7 @@ export class AddonModBookProvider { async storeLastChapterViewed(id: number, chapterId: number, courseId: number, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); - await site.storeLastViewed(AddonModBookProvider.COMPONENT, id, chapterId, String(courseId)); + await site.storeLastViewed(AddonModBookProvider.COMPONENT, id, chapterId, { data: String(courseId) }); } } diff --git a/src/addons/mod/data/pages/entry/entry.ts b/src/addons/mod/data/pages/entry/entry.ts index d7ef66e84..648726a62 100644 --- a/src/addons/mod/data/pages/entry/entry.ts +++ b/src/addons/mod/data/pages/entry/entry.ts @@ -204,7 +204,10 @@ export class AddonModDataEntryPage implements OnInit, OnDestroy { if (this.logAfterFetch) { this.logAfterFetch = false; - this.logView(); + await CoreUtils.ignoreErrors(AddonModData.logView(this.database.id, this.database.name)); + + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.moduleId); } } catch (error) { if (!refresh) { @@ -402,19 +405,6 @@ export class AddonModDataEntryPage implements OnInit, OnDestroy { AddonModData.invalidateEntryData(this.database!.id, this.entryId!); } - /** - * Log viewing the activity. - * - * @return Promise resolved when done. - */ - protected async logView(): Promise { - if (!this.database || !this.database.id) { - return; - } - - await CoreUtils.ignoreErrors(AddonModData.logView(this.database.id, this.database.name)); - } - /** * Component being destroyed. */ diff --git a/src/addons/mod/feedback/pages/attempt/attempt.ts b/src/addons/mod/feedback/pages/attempt/attempt.ts index 35733c4ed..7ee5e6028 100644 --- a/src/addons/mod/feedback/pages/attempt/attempt.ts +++ b/src/addons/mod/feedback/pages/attempt/attempt.ts @@ -16,6 +16,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRouteSnapshot } from '@angular/router'; import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; +import { CoreCourse } from '@features/course/services/course'; import { CoreNavigator } from '@services/navigator'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; @@ -49,6 +50,7 @@ export class AddonModFeedbackAttemptPage implements OnInit, OnDestroy { loaded = false; protected attemptId: number; + protected fetchSuccess = false; constructor() { this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); @@ -131,6 +133,11 @@ export class AddonModFeedbackAttemptPage implements OnInit, OnDestroy { return attemptItem; }).filter((itemData) => itemData); // Filter items with errors. + if (!this.fetchSuccess) { + this.fetchSuccess = true; + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.cmId); + } } catch (message) { // Some call failed on fetch, go back. CoreDomUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); diff --git a/src/addons/mod/feedback/pages/attempts/attempts.ts b/src/addons/mod/feedback/pages/attempts/attempts.ts index 40fe0a21f..1da06886d 100644 --- a/src/addons/mod/feedback/pages/attempts/attempts.ts +++ b/src/addons/mod/feedback/pages/attempts/attempts.ts @@ -25,6 +25,7 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { AddonModFeedbackAttemptItem, AddonModFeedbackAttemptsSource } from '../../classes/feedback-attempts-source'; import { AddonModFeedbackWSAnonAttempt, AddonModFeedbackWSAttempt } from '../../services/feedback'; +import { CoreCourse } from '@features/course/services/course'; /** * Page that displays feedback attempts. @@ -37,14 +38,14 @@ export class AddonModFeedbackAttemptsPage implements AfterViewInit, OnDestroy { @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; - promisedAttempts: CorePromisedValue>; + promisedAttempts: CorePromisedValue; fetchFailed = false; constructor(protected route: ActivatedRoute) { this.promisedAttempts = new CorePromisedValue(); } - get attempts(): CoreListItemsManager | null { + get attempts(): AddonModFeedbackAttemptsManager | null { return this.promisedAttempts.value; } @@ -95,7 +96,7 @@ export class AddonModFeedbackAttemptsPage implements AfterViewInit, OnDestroy { source.selectedGroup = CoreNavigator.getRouteNumberParam('group') || 0; - this.promisedAttempts.resolve(new CoreListItemsManager(source, this.route.component)); + this.promisedAttempts.resolve(new AddonModFeedbackAttemptsManager(source, this.route.component)); } catch (error) { CoreDomUtils.showErrorModal(error); @@ -181,3 +182,18 @@ export class AddonModFeedbackAttemptsPage implements AfterViewInit, OnDestroy { } } + +/** + * Attempts manager. + */ +class AddonModFeedbackAttemptsManager extends CoreListItemsManager { + + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.getSource().COURSE_ID, this.getSource().CM_ID); + } + +} diff --git a/src/addons/mod/feedback/pages/nonrespondents/nonrespondents.ts b/src/addons/mod/feedback/pages/nonrespondents/nonrespondents.ts index db568a32a..59b4f7ca5 100644 --- a/src/addons/mod/feedback/pages/nonrespondents/nonrespondents.ts +++ b/src/addons/mod/feedback/pages/nonrespondents/nonrespondents.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; +import { CoreCourse } from '@features/course/services/course'; import { IonRefresher } from '@ionic/angular'; import { CoreGroupInfo, CoreGroups } from '@services/groups'; import { CoreNavigator } from '@services/navigator'; @@ -34,6 +35,7 @@ export class AddonModFeedbackNonRespondentsPage implements OnInit { protected courseId!: number; protected feedback?: AddonModFeedbackWSFeedback; protected page = 0; + protected fetchSuccess = false; selectedGroup!: number; groupInfo?: CoreGroupInfo; @@ -81,6 +83,12 @@ export class AddonModFeedbackNonRespondentsPage implements OnInit { this.selectedGroup = CoreGroups.validateGroupId(this.selectedGroup, this.groupInfo); await this.loadGroupUsers(this.selectedGroup); + + if (!this.fetchSuccess) { + this.fetchSuccess = true; + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.cmId); + } } catch (message) { CoreDomUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); diff --git a/src/addons/mod/forum/pages/discussion/discussion.page.ts b/src/addons/mod/forum/pages/discussion/discussion.page.ts index 12bf5c553..0f735704b 100644 --- a/src/addons/mod/forum/pages/discussion/discussion.page.ts +++ b/src/addons/mod/forum/pages/discussion/discussion.page.ts @@ -17,6 +17,7 @@ import { Component, OnDestroy, ViewChild, OnInit, AfterViewInit, ElementRef, Opt import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreCourse } from '@features/course/services/course'; import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; import { CoreRatingInfo, CoreRatingProvider } from '@features/rating/services/rating'; import { CoreRatingOffline } from '@features/rating/services/rating-offline'; @@ -118,6 +119,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes protected ratingOfflineObserver?: CoreEventObserver; protected ratingSyncObserver?: CoreEventObserver; protected changeDiscObserver?: CoreEventObserver; + protected fetchSuccess = false; constructor( @Optional() protected splitView: CoreSplitViewComponent, @@ -547,6 +549,12 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes this.hasOfflineRatings = await CoreRatingOffline.hasRatings('mod_forum', 'post', ContextLevel.MODULE, this.cmId, this.discussionId); + + if (!this.fetchSuccess) { + this.fetchSuccess = true; + // Store module viewed. It's done in this page because it can be reached using a link. + this.courseId && this.cmId && CoreCourse.storeModuleViewed(this.courseId, this.cmId); + } } catch (error) { CoreDomUtils.showErrorModal(error); } finally { diff --git a/src/addons/mod/glossary/pages/entry/entry.ts b/src/addons/mod/glossary/pages/entry/entry.ts index 41dfb3b8c..f07b4004b 100644 --- a/src/addons/mod/glossary/pages/entry/entry.ts +++ b/src/addons/mod/glossary/pages/entry/entry.ts @@ -17,6 +17,7 @@ import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; import { CoreCommentsCommentsComponent } from '@features/comments/components/comments/comments'; import { CoreComments } from '@features/comments/services/comments'; +import { CoreCourse } from '@features/course/services/course'; import { CoreRatingInfo } from '@features/rating/services/rating'; import { CoreTag } from '@features/tag/services/tag'; import { IonRefresher } from '@ionic/angular'; @@ -55,8 +56,10 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy { tagsEnabled = false; commentsEnabled = false; courseId!: number; + cmId?: number; protected entryId!: number; + protected fetchSuccess = false; constructor(protected route: ActivatedRoute) {} @@ -72,15 +75,17 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy { this.commentsEnabled = !CoreComments.areCommentsDisabledInSite(); if (routeData.swipeEnabled ?? true) { - const cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); + this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( AddonModGlossaryEntriesSource, - [this.courseId, cmId, routeData.glossaryPathPrefix ?? ''], + [this.courseId, this.cmId, routeData.glossaryPathPrefix ?? ''], ); this.entries = new AddonModGlossaryEntryEntriesSwipeManager(source); await this.entries.start(); + } else { + this.cmId = CoreNavigator.getRouteNumberParam('cmId'); } } catch (error) { CoreDomUtils.showErrorModal(error); @@ -143,6 +148,12 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy { this.entry = result.entry; this.ratingInfo = result.ratinginfo; + if (!this.fetchSuccess) { + this.fetchSuccess = true; + // Store module viewed. It's done in this page because it can be reached using a link. + this.cmId && CoreCourse.storeModuleViewed(this.courseId, this.cmId); + } + if (this.glossary) { // Glossary already loaded, nothing else to load. return; diff --git a/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.ts b/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.ts index 3b3727a2b..458449191 100644 --- a/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.ts +++ b/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.ts @@ -25,6 +25,7 @@ import { AddonModH5PActivityData, AddonModH5PActivityAttemptResults, } from '../../services/h5pactivity'; +import { CoreCourse } from '@features/course/services/course'; /** * Page that displays results of an attempt. @@ -99,6 +100,9 @@ export class AddonModH5PActivityAttemptResultsPage implements OnInit { this.h5pActivity.name, { attemptId: this.attemptId }, )); + + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.cmId); } } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error loading attempt.'); diff --git a/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.ts b/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.ts index bb95f9fcb..a45b31b05 100644 --- a/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.ts +++ b/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.ts @@ -26,6 +26,7 @@ import { AddonModH5PActivityData, AddonModH5PActivityUserAttempts, } from '../../services/h5pactivity'; +import { CoreCourse } from '@features/course/services/course'; /** * Page that displays user attempts of a certain user. @@ -101,6 +102,9 @@ export class AddonModH5PActivityUserAttemptsPage implements OnInit { this.h5pActivity.name, { userId: this.userId }, )); + + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.cmId); } } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error loading attempts.'); diff --git a/src/addons/mod/h5pactivity/pages/users-attempts/users-attempts.ts b/src/addons/mod/h5pactivity/pages/users-attempts/users-attempts.ts index f50cd056c..2acb07e59 100644 --- a/src/addons/mod/h5pactivity/pages/users-attempts/users-attempts.ts +++ b/src/addons/mod/h5pactivity/pages/users-attempts/users-attempts.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; +import { CoreCourse } from '@features/course/services/course'; import { CoreUser, CoreUserProfile } from '@features/user/services/user'; import { IonRefresher } from '@ionic/angular'; @@ -92,6 +93,9 @@ export class AddonModH5PActivityUsersAttemptsPage implements OnInit { if (!this.fetchSuccess) { this.fetchSuccess = true; CoreUtils.ignoreErrors(AddonModH5PActivity.logViewReport(this.h5pActivity.id, this.h5pActivity.name)); + + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.cmId); } } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error loading attempts.'); diff --git a/src/addons/mod/imscp/services/imscp.ts b/src/addons/mod/imscp/services/imscp.ts index abfcd0d87..226dcd7c5 100644 --- a/src/addons/mod/imscp/services/imscp.ts +++ b/src/addons/mod/imscp/services/imscp.ts @@ -303,7 +303,7 @@ export class AddonModImscpProvider { async storeLastItemViewed(id: number, href: string, courseId: number, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); - await site.storeLastViewed(AddonModImscpProvider.COMPONENT, id, href, String(courseId)); + await site.storeLastViewed(AddonModImscpProvider.COMPONENT, id, href, { data: String(courseId) }); } } diff --git a/src/addons/mod/lesson/pages/user-retake/user-retake.page.ts b/src/addons/mod/lesson/pages/user-retake/user-retake.page.ts index 634e08740..996bb5235 100644 --- a/src/addons/mod/lesson/pages/user-retake/user-retake.page.ts +++ b/src/addons/mod/lesson/pages/user-retake/user-retake.page.ts @@ -35,6 +35,7 @@ import { } from '../../services/lesson'; import { AddonModLessonAnswerData, AddonModLessonHelper } from '../../services/lesson-helper'; import { CoreTimeUtils } from '@services/utils/time'; +import { CoreCourse } from '@features/course/services/course'; /** * Page that displays a retake made by a certain user. @@ -59,6 +60,7 @@ export class AddonModLessonUserRetakePage implements OnInit { protected userId?: number; // User ID to see the retakes. protected retakeNumber?: number; // Number of the initial retake to see. protected previousSelectedRetake?: number; // To be able to detect the previous selected retake when it has changed. + protected fetchSuccess = false; /** * Component being initialized. @@ -160,6 +162,12 @@ export class AddonModLessonUserRetakePage implements OnInit { this.student.profileimageurl = user?.profileimageurl; await this.setRetake(this.selectedRetake); + + if (!this.fetchSuccess) { + this.fetchSuccess = true; + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.cmId); + } } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error getting data.', true); } diff --git a/src/addons/mod/lti/services/handlers/module.ts b/src/addons/mod/lti/services/handlers/module.ts index 7e80a8f53..953e4264f 100644 --- a/src/addons/mod/lti/services/handlers/module.ts +++ b/src/addons/mod/lti/services/handlers/module.ts @@ -21,6 +21,7 @@ import { makeSingleton } from '@singletons'; import { AddonModLtiHelper } from '../lti-helper'; import { AddonModLtiIndexComponent } from '../../components/index'; import { CoreModuleHandlerBase } from '@features/course/classes/module-base-handler'; +import { CoreCourse } from '@features/course/services/course'; /** * Handler to support LTI modules. @@ -67,6 +68,8 @@ export class AddonModLtiModuleHandlerService extends CoreModuleHandlerBase imple action: (event: Event, module: CoreCourseModuleData, courseId: number): void => { // Launch the LTI. AddonModLtiHelper.getDataAndLaunch(courseId, module); + + CoreCourse.storeModuleViewed(courseId, module.id); }, }]; diff --git a/src/addons/mod/quiz/pages/review/review.page.ts b/src/addons/mod/quiz/pages/review/review.page.ts index e58aac16e..478d234b8 100644 --- a/src/addons/mod/quiz/pages/review/review.page.ts +++ b/src/addons/mod/quiz/pages/review/review.page.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { CoreCourse } from '@features/course/services/course'; import { CoreQuestionQuestionParsed } from '@features/question/services/question'; import { CoreQuestionHelper } from '@features/question/services/question-helper'; import { IonContent, IonRefresher } from '@ionic/angular'; @@ -163,6 +164,9 @@ export class AddonModQuizReviewPage implements OnInit { CoreUtils.ignoreErrors( AddonModQuiz.logViewAttemptReview(this.attemptId, this.quiz.id, this.quiz.name), ); + + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.cmId); } } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true); diff --git a/src/addons/mod/resource/services/handlers/module.ts b/src/addons/mod/resource/services/handlers/module.ts index 3304a1b8c..c6f7e9e82 100644 --- a/src/addons/mod/resource/services/handlers/module.ts +++ b/src/addons/mod/resource/services/handlers/module.ts @@ -90,6 +90,8 @@ export class AddonModResourceModuleHandlerService extends CoreModuleHandlerBase const hide = await this.hideOpenButton(module); if (!hide) { AddonModResourceHelper.openModuleFile(module, courseId); + + CoreCourse.storeModuleViewed(courseId, module.id); } }, }]; diff --git a/src/addons/mod/url/services/handlers/module.ts b/src/addons/mod/url/services/handlers/module.ts index 8d7adc10e..b9978c872 100644 --- a/src/addons/mod/url/services/handlers/module.ts +++ b/src/addons/mod/url/services/handlers/module.ts @@ -63,7 +63,7 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple * @param module The module object. * @param courseId The course ID. */ - const openUrl = async (module: CoreCourseModuleData): Promise => { + const openUrl = async (module: CoreCourseModuleData, courseId: number): Promise => { try { if (module.instance) { await AddonModUrl.logView(module.instance, module.name); @@ -73,6 +73,8 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple // Ignore errors. } + CoreCourse.storeModuleViewed(courseId, module.id); + const contents = await CoreCourse.getModuleContents(module); AddonModUrlHelper.open(contents[0].fileurl); }; @@ -89,7 +91,7 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple const shouldOpen = await this.shouldOpenLink(module); if (shouldOpen) { - openUrl(module); + openUrl(module, courseId); } else { this.openActivityPage(module, module.course, options); } @@ -101,8 +103,8 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple hidden: true, // Hide it until we calculate if it should be displayed or not. icon: 'fas-link', label: 'core.openmodinbrowser', - action: (event: Event, module: CoreCourseModuleData): void => { - openUrl(module); + action: (event: Event, module: CoreCourseModuleData, courseId: number): void => { + openUrl(module, courseId); }, }], }; diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index eaefeb159..dc2644d60 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -160,7 +160,7 @@ export class CoreSite { this.lastViewedTable = asyncInstance(() => CoreSites.getSiteTable(CoreSite.LAST_VIEWED_TABLE, { siteId: this.getId(), database: this.getDb(), - config: { cachingStrategy: CoreDatabaseCachingStrategy.None }, + config: { cachingStrategy: CoreDatabaseCachingStrategy.Eager }, primaryKeyColumns: ['component', 'id'], })); this.setInfo(infos); @@ -2000,21 +2000,55 @@ export class CoreSite { } } + /** + * Get several last viewed for a certain component. + * + * @param component The component. + * @param ids IDs. If not provided or empty, return all last viewed for a component. + * @return Resolves with last viewed records, undefined if error. + */ + async getComponentLastViewed(component: string, ids: number[] = []): Promise { + try { + if (!ids.length) { + return await this.lastViewedTable.getMany({ component }); + } + + const whereAndParams = SQLiteDB.getInOrEqual(ids); + + whereAndParams.sql = 'id ' + whereAndParams.sql + ' AND component = ?'; + whereAndParams.params.push(component); + + return await this.lastViewedTable.getManyWhere({ + sql: whereAndParams.sql, + sqlParams: whereAndParams.params, + js: (record) => record.component === component && ids.includes(record.id), + }); + } catch (error) { + // Not found. + } + } + /** * Store a last viewed record. * * @param component The component. * @param id ID. * @param value Last viewed item value. - * @param data Other data. + * @param options Options. * @return Promise resolved when done. */ - async storeLastViewed(component: string, id: number, value: string | number, data?: string): Promise { + async storeLastViewed( + component: string, + id: number, + value: string | number, + options: CoreSiteStoreLastViewedOptions = {}, + ): Promise { await this.lastViewedTable.insert({ component, id, value: String(value), - data, + data: options.data, + timeaccess: options.timeaccess ?? Date.now(), }); } @@ -2349,5 +2383,14 @@ export type CoreSiteLastViewedDBRecord = { component: string; id: number; value: string; + timeaccess: number; data?: string; }; + +/** + * Options for storeLastViewed. + */ +export type CoreSiteStoreLastViewedOptions = { + data?: string; // Other data. + timeaccess?: number; // Accessed time. If not set, current time. +}; diff --git a/src/core/features/course/classes/main-resource-component.ts b/src/core/features/course/classes/main-resource-component.ts index 1c3d9600f..a9600520c 100644 --- a/src/core/features/course/classes/main-resource-component.ts +++ b/src/core/features/course/classes/main-resource-component.ts @@ -442,6 +442,7 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, } this.fetchSuccess = true; + CoreCourse.storeModuleViewed(this.courseId, this.module.id, { sectionId: this.module.section }); // Log activity now. try { diff --git a/src/core/features/course/components/course-format/course-format.html b/src/core/features/course/components/course-format/course-format.html index a8ca2b304..6f4e8c236 100644 --- a/src/core/features/course/components/course-format/course-format.html +++ b/src/core/features/course/components/course-format/course-format.html @@ -97,7 +97,9 @@ + [showActivityDates]="course.showactivitydates" [showCompletionConditions]="course.showcompletionconditions" + [isLastViewed]="lastModuleViewed && lastModuleViewed.cmId === module.id" [class.core-course-module-not-viewed]=" + !viewedModules[module.id] && (!module.completiondata || module.completiondata.state === completionStatusIncomplete)"> diff --git a/src/core/features/course/components/course-format/course-format.ts b/src/core/features/course/components/course-format/course-format.ts index 5b6797f98..32c742a53 100644 --- a/src/core/features/course/components/course-format/course-format.ts +++ b/src/core/features/course/components/course-format/course-format.ts @@ -29,6 +29,7 @@ import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-comp import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; import { CoreCourse, + CoreCourseModuleCompletionStatus, CoreCourseProvider, } from '@features/course/services/course'; import { @@ -42,6 +43,7 @@ import { CoreCourseCourseIndexComponent, CoreCourseIndexSectionWithModule } from import { CoreBlockHelper } from '@features/block/services/block-helper'; import { CoreNavigator } from '@services/navigator'; import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseViewedModulesDBRecord } from '@features/course/services/database/course'; /** * Component to display course contents using a certain format. If the format isn't found, use default one. @@ -90,9 +92,14 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { stealthModulesSectionId: number = CoreCourseProvider.STEALTH_MODULES_SECTION_ID; loaded = false; highlighted?: string; + lastModuleViewed?: CoreCourseViewedModulesDBRecord; + viewedModules: Record = {}; + completionStatusIncomplete = CoreCourseModuleCompletionStatus.COMPLETION_INCOMPLETE; protected selectTabObserver?: CoreEventObserver; + protected modViewedObserver?: CoreEventObserver; protected lastCourseFormat?: string; + protected viewedModulesInitialized = false; constructor( protected content: IonContent, @@ -133,6 +140,16 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { } }); + this.modViewedObserver = CoreEvents.on(CoreEvents.COURSE_MODULE_VIEWED, (data) => { + if (data.courseId !== this.course.id) { + return; + } + + this.viewedModules[data.cmId] = true; + if (!this.lastModuleViewed || data.timeaccess > this.lastModuleViewed.timeaccess) { + this.lastModuleViewed = data; + } + }); } /** @@ -179,8 +196,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { this.lastCourseFormat = this.course.format; this.highlighted = CoreCourseFormatDelegate.getSectionHightlightedName(this.course); - const currentSection = await CoreCourseFormatDelegate.getCurrentSection(this.course, this.sections); - currentSection.highlighted = true; + const currentSectionData = await CoreCourseFormatDelegate.getCurrentSection(this.course, this.sections); + currentSectionData.section.highlighted = true; await Promise.all([ this.loadCourseFormatComponent(), @@ -236,6 +253,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { const hasAllSections = sections[0].id == CoreCourseProvider.ALL_SECTIONS_ID; const hasSeveralSections = sections.length > 2 || (sections.length == 2 && !hasAllSections); + await this.initializeViewedModules(); + if (this.selectedSection) { const selectedSection = this.selectedSection; // We have a selected section, but the list has changed. Search the section in the list. @@ -243,7 +262,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { if (!newSection) { // Section not found, calculate which one to use. - newSection = await CoreCourseFormatDelegate.getCurrentSection(this.course, sections); + const currentSectionData = await CoreCourseFormatDelegate.getCurrentSection(this.course, sections); + newSection = currentSectionData.section; } this.sectionChanged(newSection); @@ -269,16 +289,60 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { } if (!this.loaded) { - // No section specified, not found or not visible, get current section. - const section = await CoreCourseFormatDelegate.getCurrentSection(this.course, sections); + // No section specified, not found or not visible, load current section or the section with last module viewed. + const currentSectionData = await CoreCourseFormatDelegate.getCurrentSection(this.course, sections); + + const lastModuleViewed = this.lastModuleViewed; + let section = currentSectionData.section; + let moduleId: number | undefined; + + if (!currentSectionData.forceSelected && lastModuleViewed) { + // Search the section with the last module viewed. + let lastModuleSection: CoreCourseSection | undefined; + + if (lastModuleViewed.sectionId) { + lastModuleSection = sections.find(section => section.id === lastModuleViewed.sectionId); + } + if (!lastModuleSection) { + // No sectionId or section not found. Search the module. + lastModuleSection = sections.find( + section => section.modules.some(module => module.id === lastModuleViewed.cmId), + ); + } + + section = lastModuleSection || section; + moduleId = lastModuleSection ? lastModuleViewed?.cmId : undefined; + } else if (lastModuleViewed && currentSectionData.section.modules.some(module => module.id === lastModuleViewed.cmId)) { + // Last module viewed is inside the highlighted section. + moduleId = lastModuleViewed.cmId; + } this.loaded = true; - this.sectionChanged(section); + this.sectionChanged(section, moduleId); } return; } + /** + * Initialize viewed modules. + * + * @return Promise resolved when done. + */ + protected async initializeViewedModules(): Promise { + if (this.viewedModulesInitialized) { + return; + } + + const viewedModules = await CoreCourse.getViewedModules(this.course.id); + + this.viewedModulesInitialized = true; + this.lastModuleViewed = viewedModules[0]; + viewedModules.forEach(entry => { + this.viewedModules[entry.cmId] = true; + }); + } + /** * Display the course index modal. */ @@ -345,8 +409,9 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { * Function called when selected section changes. * * @param newSection The new selected section. + * @param moduleId The module to scroll to. */ - sectionChanged(newSection: CoreCourseSection): void { + sectionChanged(newSection: CoreCourseSection, moduleId?: number): void { const previousValue = this.selectedSection; this.selectedSection = newSection; this.data.section = this.selectedSection; @@ -377,12 +442,14 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { this.showMoreActivities(); } - if (this.moduleId && previousValue === undefined) { + // Scroll to module if needed. Give more priority to the input. + moduleId = this.moduleId && previousValue === undefined ? this.moduleId : moduleId; + if (moduleId) { setTimeout(() => { CoreDomUtils.scrollToElementBySelector( this.elementRef.nativeElement, this.content, - '#core-course-module-' + this.moduleId, + '#core-course-module-' + moduleId, ); }, 200); } else { @@ -502,7 +569,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { * @inheritdoc */ ngOnDestroy(): void { - this.selectTabObserver && this.selectTabObserver.off(); + this.selectTabObserver?.off(); + this.modViewedObserver?.off(); } /** diff --git a/src/core/features/course/components/course-index/course-index.ts b/src/core/features/course/components/course-index/course-index.ts index a36cee588..e530867f0 100644 --- a/src/core/features/course/components/course-index/course-index.ts +++ b/src/core/features/course/components/course-index/course-index.ts @@ -68,11 +68,11 @@ export class CoreCourseCourseIndexComponent implements OnInit { completionEnabled = this.course.showcompletionconditions; } - const currentSection = await CoreCourseFormatDelegate.getCurrentSection(this.course, this.sections); + const currentSectionData = await CoreCourseFormatDelegate.getCurrentSection(this.course, this.sections); if (this.selectedId === undefined) { // Highlight current section if none is selected. - this.selectedId = currentSection.id; + this.selectedId = currentSectionData.section.id; } // Clone sections to add information. @@ -104,7 +104,7 @@ export class CoreCourseCourseIndexComponent implements OnInit { name: section.name, availabilityinfo: !!section.availabilityinfo, expanded: section.id === this.selectedId, - highlighted: currentSection?.id === section.id, + highlighted: currentSectionData.section.id === section.id, hasVisibleModules: modules.length > 0, modules: modules, }; diff --git a/src/core/features/course/components/module/core-course-module.html b/src/core/features/course/components/module/core-course-module.html index 60e2ece03..f8b7f88a3 100644 --- a/src/core/features/course/components/module/core-course-module.html +++ b/src/core/features/course/components/module/core-course-module.html @@ -99,6 +99,11 @@ + +
+ + {{ 'core.course.lastaccessedactivity' | translate }} +
diff --git a/src/core/features/course/components/module/module.scss b/src/core/features/course/components/module/module.scss index 4c06e9ca0..be50d8664 100644 --- a/src/core/features/course/components/module/module.scss +++ b/src/core/features/course/components/module/module.scss @@ -3,6 +3,7 @@ :host { --horizontal-margin: 10px; --vertical-margin: 10px; + --core-course-module-not-viewed-border-color: var(--gray-500); ion-card { margin: var(--vertical-margin) var(--horizontal-margin); @@ -91,4 +92,18 @@ .core-course-module-info ::ng-deep core-course-module-completion .core-module-automatic-completion-conditions .completioninfo.completion_complete { display: none; } + + &.core-course-module-not-viewed { + --ion-card-border-color: var(--core-course-module-not-viewed-border-color); + } + + .core-course-last-module-viewed { + padding: 8px 12px; + color: var(--subdued-text-color); + border-top: 1px solid var(--stroke); + + ion-icon { + margin-right: 4px; + } + } } diff --git a/src/core/features/course/components/module/module.ts b/src/core/features/course/components/module/module.ts index 29676e4ca..471e7e7d9 100644 --- a/src/core/features/course/components/module/module.ts +++ b/src/core/features/course/components/module/module.ts @@ -48,6 +48,7 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { @Input() showActivityDates = false; // Whether to show activity dates. @Input() showCompletionConditions = false; // Whether to show activity completion conditions. @Input() showLegacyCompletion?: boolean; // Whether to show module completion in the old format. + @Input() isLastViewed = false; // Whether it's the last module viewed in a course. @Output() completionChanged = new EventEmitter(); // Notify when module completion changes. modNameTranslated = ''; diff --git a/src/core/features/course/format/weeks/services/handlers/weeks-format.ts b/src/core/features/course/format/weeks/services/handlers/weeks-format.ts index 3af676006..2d05d2700 100644 --- a/src/core/features/course/format/weeks/services/handlers/weeks-format.ts +++ b/src/core/features/course/format/weeks/services/handlers/weeks-format.ts @@ -15,7 +15,7 @@ import { Injectable } from '@angular/core'; import { CoreTimeUtils } from '@services/utils/time'; -import { CoreCourseFormatHandler } from '@features/course/services/format-delegate'; +import { CoreCourseFormatCurrentSectionData, CoreCourseFormatHandler } from '@features/course/services/format-delegate'; import { makeSingleton, Translate } from '@singletons'; import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; import { CoreCourseWSSection } from '@features/course/services/course'; @@ -41,12 +41,18 @@ export class CoreCourseFormatWeeksHandlerService implements CoreCourseFormatHand /** * @inheritdoc */ - async getCurrentSection(course: CoreCourseAnyCourseData, sections: CoreCourseSection[]): Promise { + async getCurrentSection( + course: CoreCourseAnyCourseData, + sections: CoreCourseSection[], + ): Promise> { const now = CoreTimeUtils.timestamp(); if ((course.startdate && now < course.startdate) || (course.enddate && now > course.enddate)) { // Course hasn't started yet or it has ended already. Return all sections. - return sections[0]; + return { + section: sections[0], + forceSelected: false, + }; } for (let i = 0; i < sections.length; i++) { @@ -57,12 +63,18 @@ export class CoreCourseFormatWeeksHandlerService implements CoreCourseFormatHand const dates = this.getSectionDates(section, course.startdate || 0); if (now >= dates.start && now < dates.end) { - return section; + return { + section, + forceSelected: false, + }; } } // The section wasn't found, return all sections. - return sections[0]; + return { + section: sections[0], + forceSelected: false, + }; } /** diff --git a/src/core/features/course/lang.json b/src/core/features/course/lang.json index efa7b5613..459926c1e 100644 --- a/src/core/features/course/lang.json +++ b/src/core/features/course/lang.json @@ -44,6 +44,7 @@ "highlighted": "Highlighted", "insufficientavailablespace": "You are trying to download {{size}}. This will leave your device with insufficient space to operate normally. Please clear some storage space first.", "insufficientavailablequota": "Your device could not allocate space to save this download. It may be reserving space for app and system updates. Please clear some storage space first.", + "lastaccessedactivity": "Last accessed activity", "manualcompletionnotsynced": "Manual completion not synchronised.", "modulenotfound": "Resource or activity not found, please make sure you're online and it's still available.", "nocontentavailable": "No content available at the moment.", diff --git a/src/core/features/course/services/course-helper.ts b/src/core/features/course/services/course-helper.ts index e14cae7f0..7dc88fd7b 100644 --- a/src/core/features/course/services/course-helper.ts +++ b/src/core/features/course/services/course-helper.ts @@ -2022,6 +2022,7 @@ export type CoreCourseModuleData = Omit>>; + protected viewedModulesTables: LazyMap>>; constructor() { this.logger = CoreLogger.getInstance('CoreCourseProvider'); @@ -152,6 +156,17 @@ export class CoreCourseProvider { }), ), ); + + this.viewedModulesTables = lazyMap( + siteId => asyncInstance( + () => CoreSites.getSiteTable(COURSE_VIEWED_MODULES_TABLE, { + siteId, + config: { cachingStrategy: CoreDatabaseCachingStrategy.None }, + primaryKeyColumns: ['courseId', 'cmId'], + onDestroy: () => delete this.viewedModulesTables[siteId], + }), + ), + ); } /** @@ -340,6 +355,31 @@ export class CoreCourseProvider { return ROOT_CACHE_KEY + 'activitiescompletion:' + courseId + ':' + userId; } + /** + * Get certain module viewed records in the app. + * + * @param ids Module IDs. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with map of last module viewed data. + */ + async getCertainModulesViewed(ids: number[] = [], siteId?: string): Promise> { + if (!ids.length) { + return {}; + } + + const site = await CoreSites.getSite(siteId); + + const whereAndParams = SQLiteDB.getInOrEqual(ids); + + const entries = await this.viewedModulesTables[site.getId()].getManyWhere({ + sql: 'cmId ' + whereAndParams.sql, + sqlParams: whereAndParams.params, + js: (record) => ids.includes(record.cmId), + }); + + return CoreUtils.arrayToObject(entries, 'cmId'); + } + /** * Get course blocks. * @@ -425,6 +465,19 @@ export class CoreCourseProvider { return entries.map((entry) => entry.id); } + /** + * Get last module viewed in the app for a course. + * + * @param id Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with last module viewed data, undefined if none. + */ + async getLastModuleViewed(id: number, siteId?: string): Promise { + const viewedModules = await this.getViewedModules(id, siteId); + + return viewedModules[0]; + } + /** * Get a module from Moodle. * @@ -548,7 +601,7 @@ export class CoreCourseProvider { let foundModule: CoreCourseGetContentsWSModule | undefined; - const foundSection = sections.some((section) => { + const foundSection = sections.find((section) => { if (section.id != CoreCourseProvider.STEALTH_MODULES_SECTION_ID && sectionId !== undefined && sectionId != section.id @@ -562,7 +615,7 @@ export class CoreCourseProvider { }); if (foundSection && foundModule) { - return this.addAdditionalModuleData(foundModule, courseId); + return this.addAdditionalModuleData(foundModule, courseId, foundSection.id); } throw new CoreError(Translate.instant('core.course.modulenotfound')); @@ -573,11 +626,13 @@ export class CoreCourseProvider { * * @param module Module. * @param courseId Course ID of the module. + * @param sectionId Section ID of the module. * @return Module with additional info. */ - protected addAdditionalModuleData( + protected addAdditionalModuleData( module: CoreCourseGetContentsWSModule, courseId: number, + sectionId: number, ): CoreCourseModuleData { let completionData: CoreCourseModuleCompletionData | undefined = undefined; @@ -590,13 +645,12 @@ export class CoreCourseProvider { }; } - const moduleWithCourse: CoreCourseModuleData = { + return { ...module, course: courseId, + section: sectionId, completiondata: completionData, }; - - return moduleWithCourse; } /** @@ -873,7 +927,7 @@ export class CoreCourseProvider { // Add course to all modules. return sections.map((section) => ({ ...section, - modules: section.modules.map((module) => this.addAdditionalModuleData(module, courseId)), + modules: section.modules.map((module) => this.addAdditionalModuleData(module, courseId, section.id)), })); } @@ -901,6 +955,23 @@ export class CoreCourseProvider { return sections.reduce((previous: CoreCourseModuleData[], section) => previous.concat(section.modules || []), []); } + /** + * Get all viewed modules in a course, ordered by timeaccess in descending order. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of viewed modules. + */ + async getViewedModules(courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return await this.viewedModulesTables[site.getId()].getMany({ courseId }, { + sorting: [ + { timeaccess: 'desc' }, + ], + }); + } + /** * Invalidates course blocks WS call. * @@ -1348,6 +1419,34 @@ export class CoreCourseProvider { this.triggerCourseStatusChanged(courseId, status, siteId); } + /** + * Store activity as viewed. + * + * @param courseId Chapter ID. + * @param cmId Module ID. + * @param options Other options. + * @return Promise resolved with last chapter viewed, undefined if none. + */ + async storeModuleViewed(courseId: number, cmId: number, options: CoreCourseStoreModuleViewedOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const timeaccess = options.timeaccess ?? Date.now(); + + await this.viewedModulesTables[site.getId()].insert({ + courseId, + cmId, + sectionId: options.sectionId, + timeaccess, + }); + + CoreEvents.trigger(CoreEvents.COURSE_MODULE_VIEWED, { + courseId, + cmId, + timeaccess, + sectionId: options.sectionId, + }, site.getId()); + } + /** * Translate a module name to current language. * @@ -1770,3 +1869,12 @@ type CoreCompletionUpdateActivityCompletionStatusManuallyWSParams = { export type CoreCourseAnyModuleData = CoreCourseModuleData | CoreCourseModuleBasicInfo & { contents?: CoreCourseModuleContentFile[]; // If needed, calculated in the app in loadModuleContents. }; + +/** + * Options for storeModuleViewed. + */ +export type CoreCourseStoreModuleViewedOptions = { + sectionId?: number; + timeaccess?: number; + siteId?: string; +}; diff --git a/src/core/features/course/services/database/course.ts b/src/core/features/course/services/database/course.ts index 7869b8a55..47fa10b87 100644 --- a/src/core/features/course/services/database/course.ts +++ b/src/core/features/course/services/database/course.ts @@ -18,9 +18,10 @@ import { CoreSiteSchema } from '@services/sites'; * Database variables for CoreCourse service. */ export const COURSE_STATUS_TABLE = 'course_status'; +export const COURSE_VIEWED_MODULES_TABLE = 'course_viewed_modules'; export const SITE_SCHEMA: CoreSiteSchema = { name: 'CoreCourseProvider', - version: 1, + version: 2, tables: [ { name: COURSE_STATUS_TABLE, @@ -53,6 +54,29 @@ export const SITE_SCHEMA: CoreSiteSchema = { }, ], }, + { + name: COURSE_VIEWED_MODULES_TABLE, + columns: [ + { + name: 'courseId', + type: 'INTEGER', + }, + { + name: 'cmId', + type: 'INTEGER', + }, + { + name: 'timeaccess', + type: 'INTEGER', + notNull: true, + }, + { + name: 'sectionId', + type: 'INTEGER', + }, + ], + primaryKeys: ['courseId', 'cmId'], + }, ], }; @@ -85,7 +109,7 @@ export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { type: 'TEXT', }, { - name: 'timecompleted', + name: 'timeaccess', type: 'INTEGER', }, ], @@ -102,6 +126,13 @@ export type CoreCourseStatusDBRecord = { previousDownloadTime: number; }; +export type CoreCourseViewedModulesDBRecord = { + courseId: number; + cmId: number; + timeaccess: number; + sectionId?: number; +}; + export type CoreCourseManualCompletionDBRecord = { cmid: number; completed: number; diff --git a/src/core/features/course/services/format-delegate.ts b/src/core/features/course/services/format-delegate.ts index 94a4bcc4c..12f87c94d 100644 --- a/src/core/features/course/services/format-delegate.ts +++ b/src/core/features/course/services/format-delegate.ts @@ -98,9 +98,13 @@ export interface CoreCourseFormatHandler extends CoreDelegateHandler { * * @param course The course to get the title. * @param sections List of sections. - * @return Promise resolved with current section. + * @return Promise resolved with current section and whether the section should be selected. If only the section is returned, + * forceSelected will default to false. */ - getCurrentSection?(course: CoreCourseAnyCourseData, sections: CoreCourseSection[]): Promise; + getCurrentSection?( + course: CoreCourseAnyCourseData, + sections: CoreCourseSection[], + ): Promise | CoreCourseSection>; /** * Returns the name for the highlighted section. @@ -299,21 +303,37 @@ export class CoreCourseFormatDelegateService extends CoreDelegate(course: CoreCourseAnyCourseData, sections: T[]): Promise { + async getCurrentSection( + course: CoreCourseAnyCourseData, + sections: T[], + ): Promise> { try { - const section = await this.executeFunctionOnEnabled( + const sectionData = await this.executeFunctionOnEnabled | T>( course.format || '', 'getCurrentSection', [course, sections], ); - return section || sections[0]; + if (sectionData && 'forceSelected' in sectionData) { + return sectionData; + } else if (sectionData) { + // Function just returned the section, don't force selecting it. + return { + section: sectionData, + forceSelected: false, + }; + } } catch { - // This function should never fail. Just return the first section (usually, "All sections"). - return sections[0]; + // This function should never fail. } + + // Return the first section (usually, "All sections"). + return { + section: sections[0], + forceSelected: false, + }; } /** @@ -380,3 +400,8 @@ export class CoreCourseFormatDelegateService extends CoreDelegate = { + section: T; // Current section. + forceSelected: boolean; // If true, the app will force selecting the section when opening the course. +}; diff --git a/src/core/features/course/services/handlers/default-format.ts b/src/core/features/course/services/handlers/default-format.ts index ff60f6c39..383a645fc 100644 --- a/src/core/features/course/services/handlers/default-format.ts +++ b/src/core/features/course/services/handlers/default-format.ts @@ -18,7 +18,7 @@ import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { CoreCourseSection } from '../course-helper'; -import { CoreCourseFormatHandler } from '../format-delegate'; +import { CoreCourseFormatCurrentSectionData, CoreCourseFormatHandler } from '../format-delegate'; /** * Default handler used when the course format doesn't have a specific implementation. @@ -74,7 +74,10 @@ export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler { /** * @inheritdoc */ - async getCurrentSection(course: CoreCourseAnyCourseData, sections: CoreCourseSection[]): Promise { + async getCurrentSection( + course: CoreCourseAnyCourseData, + sections: CoreCourseSection[], + ): Promise> { let marker: number | undefined; // We need the "marker" to determine the current section. @@ -93,12 +96,18 @@ export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler { const section = sections.find((sect) => sect.section == marker); if (section) { - return section; + return { + section, + forceSelected: true, + }; } } // Marked section not found or we couldn't retrieve the marker. Return all sections. - return sections[0]; + return { + section: sections[0], + forceSelected: false, + }; } /** diff --git a/src/core/services/database/sites.ts b/src/core/services/database/sites.ts index 8d2083e1d..80aadcd48 100644 --- a/src/core/services/database/sites.ts +++ b/src/core/services/database/sites.ts @@ -145,6 +145,10 @@ export const SITE_SCHEMA: CoreSiteSchema = { name: 'data', type: 'TEXT', }, + { + name: 'timeaccess', + type: 'INTEGER', + }, ], primaryKeys: ['component', 'id'], }, diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 57d112fd8..4045c8212 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -60,6 +60,7 @@ export interface CoreEventsData { [CoreEvents.FILE_SHARED]: CoreEventFileSharedData; [CoreEvents.APP_LAUNCHED_URL]: CoreEventAppLaunchedData; [CoreEvents.ORIENTATION_CHANGE]: CoreEventOrientationData; + [CoreEvents.COURSE_MODULE_VIEWED]: CoreEventCourseModuleViewed; } /* @@ -110,6 +111,7 @@ export class CoreEvents { static readonly FORM_ACTION = 'form_action'; static readonly ACTIVITY_DATA_SENT = 'activity_data_sent'; static readonly DEVICE_REGISTERED_IN_MOODLE = 'device_registered_in_moodle'; + static readonly COURSE_MODULE_VIEWED = 'course_module_viewed'; protected static logger = CoreLogger.getInstance('CoreEvents'); protected static observables: { [eventName: string]: Subject } = {}; @@ -427,3 +429,13 @@ export type CoreEventAppLaunchedData = { export type CoreEventOrientationData = { orientation: CoreScreenOrientation; }; + +/** + * Data passed to COURSE_MODULE_VIEWED event. + */ +export type CoreEventCourseModuleViewed = { + courseId: number; + cmId: number; + timeaccess: number; + sectionId?: number; +}; diff --git a/upgrade.txt b/upgrade.txt index e01fe0058..5bb5fdf59 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -22,7 +22,8 @@ information provided here is intended especially for developers. - The parameters of the following functions in CoreCourseHelper have changed: navigateToModuleByInstance, navigateToModule, openModule. - fillContextMenu, expandDescription, gotoBlog, prefetch and removeFiles functions have been removed from CoreCourseModuleMainResourceComponent. - contextMenuPrefetch and fillContextMenu have been removed from CoreCourseHelper. --The variable "loaded" in CoreCourseModuleMainResourceComponent has been changed to "showLoading" to reflect its purpose better. +- The variable "loaded" in CoreCourseModuleMainResourceComponent has been changed to "showLoading" to reflect its purpose better. +- The function getCurrentSection of course formats can now return a forceSelected boolean along with the section (defaults to false if not returned).