MOBILE-3930 course: Store and display modules viewed and last
parent
d10fa5db3c
commit
b72e247f81
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -391,7 +391,7 @@ export class AddonModBookProvider {
|
|||
async storeLastChapterViewed(id: number, chapterId: number, courseId: number, siteId?: string): Promise<void> {
|
||||
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) });
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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<void> {
|
||||
if (!this.database || !this.database.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await CoreUtils.ignoreErrors(AddonModData.logView(this.database.id, this.database.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Component being destroyed.
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<CoreListItemsManager<AddonModFeedbackAttemptItem, AddonModFeedbackAttemptsSource>>;
|
||||
promisedAttempts: CorePromisedValue<AddonModFeedbackAttemptsManager>;
|
||||
fetchFailed = false;
|
||||
|
||||
constructor(protected route: ActivatedRoute) {
|
||||
this.promisedAttempts = new CorePromisedValue();
|
||||
}
|
||||
|
||||
get attempts(): CoreListItemsManager<AddonModFeedbackAttemptItem, AddonModFeedbackAttemptsSource> | 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<AddonModFeedbackAttemptItem, AddonModFeedbackAttemptsSource> {
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected async logActivity(): Promise<void> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.');
|
||||
|
|
|
@ -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.');
|
||||
|
|
|
@ -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.');
|
||||
|
|
|
@ -303,7 +303,7 @@ export class AddonModImscpProvider {
|
|||
async storeLastItemViewed(id: number, href: string, courseId: number, siteId?: string): Promise<void> {
|
||||
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) });
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
}];
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
}];
|
||||
|
|
|
@ -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<void> => {
|
||||
const openUrl = async (module: CoreCourseModuleData, courseId: number): Promise<void> => {
|
||||
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);
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
|
|
@ -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<CoreSiteLastViewedDBRecord[] | undefined> {
|
||||
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<void> {
|
||||
async storeLastViewed(
|
||||
component: string,
|
||||
id: number,
|
||||
value: string | number,
|
||||
options: CoreSiteStoreLastViewedOptions = {},
|
||||
): Promise<void> {
|
||||
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.
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -97,7 +97,9 @@
|
|||
|
||||
<ng-container *ngFor="let module of section.modules">
|
||||
<core-course-module *ngIf="module.visibleoncoursepage !== 0" [module]="module" [section]="section"
|
||||
[showActivityDates]="course.showactivitydates" [showCompletionConditions]="course.showcompletionconditions">
|
||||
[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)">
|
||||
</core-course-module>
|
||||
</ng-container>
|
||||
</section>
|
||||
|
|
|
@ -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<number, boolean> = {};
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -99,6 +99,11 @@
|
|||
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<div class="core-course-last-module-viewed" *ngIf="isLastViewed">
|
||||
<ion-icon name="fas-eye" aria-hidden="true"></ion-icon>
|
||||
{{ 'core.course.lastaccessedactivity' | translate }}
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Loading. -->
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<CoreCourseModuleCompletionData>(); // Notify when module completion changes.
|
||||
|
||||
modNameTranslated = '';
|
||||
|
|
|
@ -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<CoreCourseSection> {
|
||||
async getCurrentSection(
|
||||
course: CoreCourseAnyCourseData,
|
||||
sections: CoreCourseSection[],
|
||||
): Promise<CoreCourseFormatCurrentSectionData<CoreCourseSection>> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -2022,6 +2022,7 @@ export type CoreCourseModuleData = Omit<CoreCourseGetContentsWSModule, 'completi
|
|||
isStealth?: boolean;
|
||||
handlerData?: CoreCourseModuleHandlerData;
|
||||
completiondata?: CoreCourseModuleCompletionData;
|
||||
section: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -26,7 +26,9 @@ import { CoreConstants } from '@/core/constants';
|
|||
import { makeSingleton, Platform, Translate } from '@singletons';
|
||||
import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws';
|
||||
|
||||
import { CoreCourseStatusDBRecord, COURSE_STATUS_TABLE } from './database/course';
|
||||
import {
|
||||
CoreCourseStatusDBRecord, CoreCourseViewedModulesDBRecord, COURSE_STATUS_TABLE, COURSE_VIEWED_MODULES_TABLE ,
|
||||
} from './database/course';
|
||||
import { CoreCourseOffline } from './course-offline';
|
||||
import { CoreError } from '@classes/errors/error';
|
||||
import {
|
||||
|
@ -49,6 +51,7 @@ import { lazyMap, LazyMap } from '@/core/utils/lazy-map';
|
|||
import { asyncInstance, AsyncInstance } from '@/core/utils/async-instance';
|
||||
import { CoreDatabaseTable } from '@classes/database/database-table';
|
||||
import { CoreDatabaseCachingStrategy } from '@classes/database/database-table-proxy';
|
||||
import { SQLiteDB } from '@classes/sqlitedb';
|
||||
|
||||
const ROOT_CACHE_KEY = 'mmCourse:';
|
||||
|
||||
|
@ -140,6 +143,7 @@ export class CoreCourseProvider {
|
|||
|
||||
protected logger: CoreLogger;
|
||||
protected statusTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreCourseStatusDBRecord>>>;
|
||||
protected viewedModulesTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreCourseViewedModulesDBRecord, 'courseId' | 'cmId'>>>;
|
||||
|
||||
constructor() {
|
||||
this.logger = CoreLogger.getInstance('CoreCourseProvider');
|
||||
|
@ -152,6 +156,17 @@ export class CoreCourseProvider {
|
|||
}),
|
||||
),
|
||||
);
|
||||
|
||||
this.viewedModulesTables = lazyMap(
|
||||
siteId => asyncInstance(
|
||||
() => CoreSites.getSiteTable<CoreCourseViewedModulesDBRecord, 'courseId' | 'cmId'>(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<Record<number, CoreCourseViewedModulesDBRecord>> {
|
||||
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<CoreCourseViewedModulesDBRecord | undefined> {
|
||||
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<CoreCourseViewedModulesDBRecord[]> {
|
||||
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<void> {
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<CoreCourseSection>;
|
||||
getCurrentSection?(
|
||||
course: CoreCourseAnyCourseData,
|
||||
sections: CoreCourseSection[],
|
||||
): Promise<CoreCourseFormatCurrentSectionData<CoreCourseSection> | CoreCourseSection>;
|
||||
|
||||
/**
|
||||
* Returns the name for the highlighted section.
|
||||
|
@ -299,21 +303,37 @@ export class CoreCourseFormatDelegateService extends CoreDelegate<CoreCourseForm
|
|||
*
|
||||
* @param course The course to get the title.
|
||||
* @param sections List of sections.
|
||||
* @return Promise resolved with current section.
|
||||
* @return Promise.
|
||||
*/
|
||||
async getCurrentSection<T = CoreCourseSection>(course: CoreCourseAnyCourseData, sections: T[]): Promise<T> {
|
||||
async getCurrentSection<T = CoreCourseSection>(
|
||||
course: CoreCourseAnyCourseData,
|
||||
sections: T[],
|
||||
): Promise<CoreCourseFormatCurrentSectionData<T>> {
|
||||
try {
|
||||
const section = await this.executeFunctionOnEnabled<T>(
|
||||
const sectionData = await this.executeFunctionOnEnabled<CoreCourseFormatCurrentSectionData<T> | 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<CoreCourseForm
|
|||
}
|
||||
|
||||
export const CoreCourseFormatDelegate = makeSingleton(CoreCourseFormatDelegateService);
|
||||
|
||||
export type CoreCourseFormatCurrentSectionData<T = CoreCourseSection> = {
|
||||
section: T; // Current section.
|
||||
forceSelected: boolean; // If true, the app will force selecting the section when opening the course.
|
||||
};
|
||||
|
|
|
@ -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<CoreCourseSection> {
|
||||
async getCurrentSection(
|
||||
course: CoreCourseAnyCourseData,
|
||||
sections: CoreCourseSection[],
|
||||
): Promise<CoreCourseFormatCurrentSectionData<CoreCourseSection>> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -145,6 +145,10 @@ export const SITE_SCHEMA: CoreSiteSchema = {
|
|||
name: 'data',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
name: 'timeaccess',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
],
|
||||
primaryKeys: ['component', 'id'],
|
||||
},
|
||||
|
|
|
@ -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<unknown> } = {};
|
||||
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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).
|
||||
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue