MOBILE-3305: Add page to manage downloaded courses storage

main
Noel De Martin 2020-06-03 12:37:15 +02:00
parent 16ebd1c72e
commit 671c01a660
15 changed files with 438 additions and 5 deletions

View File

@ -1,5 +1,6 @@
{
"deletecourse": "Offload all course data",
"deletecourses": "Offload all courses data",
"deletedatafrom": "Offload data from {{name}}",
"info": "Files stored on your device make the app work faster and enable the app to be used offline. You can safely offload files if you need to free up storage space.",
"managestorage": "Manage storage",

View File

@ -98,7 +98,17 @@ export class AddonStorageManagerCourseStoragePage {
*
* (This works by deleting data for each module on the course that has data.)
*/
deleteForCourse(): void {
async deleteForCourse(): Promise<void> {
try {
await this.domUtils.showDeleteConfirm('core.course.confirmdeletemodulefiles');
} catch (error) {
if (!error.coreCanceled) {
throw error;
}
return;
}
const modules = [];
this.sections.forEach((section) => {
section.modules.forEach((module) => {
@ -118,7 +128,17 @@ export class AddonStorageManagerCourseStoragePage {
*
* @param section Section object with information about section and modules
*/
deleteForSection(section: any): void {
async deleteForSection(section: any): Promise<void> {
try {
await this.domUtils.showDeleteConfirm('core.course.confirmdeletemodulefiles');
} catch (error) {
if (!error.coreCanceled) {
throw error;
}
return;
}
const modules = [];
section.modules.forEach((module) => {
if (module.totalSize > 0) {
@ -134,10 +154,22 @@ export class AddonStorageManagerCourseStoragePage {
*
* @param module Module details
*/
deleteForModule(module: any): void {
if (module.totalSize > 0) {
this.deleteModules([module]);
async deleteForModule(module: any): Promise<void> {
if (module.totalSize === 0) {
return;
}
try {
await this.domUtils.showDeleteConfirm('core.course.confirmdeletemodulefiles');
} catch (error) {
if (!error.coreCanceled) {
throw error;
}
return;
}
this.deleteModules([module]);
}
/**

View File

@ -0,0 +1,40 @@
<ion-header>
<ion-navbar core-back-button>
<ion-title>{{ 'addon.storagemanager.managestorage' | translate }}</ion-title>
</ion-navbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="loaded">
<ion-card>
<ion-card-header>
<h1 text-wrap>{{ 'core.courses.courses' | translate }}</h1>
<p text-wrap>{{ 'addon.storagemanager.info' | translate }}</p>
<ion-item no-padding padding-top class="size" text-wrap>
<ion-icon name="cube" item-start></ion-icon>
<h2 text-wrap>{{ 'addon.storagemanager.storageused' | translate }}</h2>
<div item-end>
<p text-end>{{ totalSize | coreBytesToSize }}</p>
</div>
<button ion-button icon-only item-end no-padding (click)="deleteCompletelyDownloadedCourses()" [disabled]="completelyDownloadedCourses.length === 0">
<core-icon name="trash" label="{{ 'addon.storagemanager.deletecourses' | translate }}"></core-icon>
</button>
</ion-item>
</ion-card-header>
</ion-card>
<ion-card>
<ion-list>
<ion-item *ngFor="let course of downloadedCourses" class="course">
<h2 text-wrap>{{ course.displayname }}</h2>
<h3 *ngIf="course.isDownloading">{{ 'core.downloading' | translate }}</h3>
<p>
<ion-icon name="cube" item-start></ion-icon>
{{ course.totalSize | coreBytesToSize }}
</p>
<button ion-button icon-only item-end (click)="deleteCourse(course)" [disabled]="course.isDownloading">
<core-icon name="trash" label="{{ 'addon.storagemanager.deletedatafrom' | translate: { name: course.name } }}"></core-icon>
</button>
</ion-item>
</ion-list>
</ion-card>
</core-loading>
</ion-content>

View File

@ -0,0 +1,36 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CorePipesModule } from '@pipes/pipes.module';
import { AddonStorageManagerCoursesStoragePage } from './courses-storage';
@NgModule({
declarations: [
AddonStorageManagerCoursesStoragePage,
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
CorePipesModule,
IonicPageModule.forChild(AddonStorageManagerCoursesStoragePage),
TranslateModule.forChild()
],
})
export class AddonStorageManagerCoursesStoragePageModule {
}

View File

@ -0,0 +1,28 @@
ion-app.app-root page-addon-storagemanager-courses-storage {
.item-md.item-block .item-inner {
padding-right: 0;
padding-left: 0;
}
ion-item.course {
border-bottom: 1px solid $list-border-color;
padding-right: 16px;
padding-left: 16px;
h2 {
font-weight: bold;
font-size: 2rem;
}
h3 {
color: $subdued-text-color;
}
&:last-child {
border-bottom: 0;
}
}
}

View File

@ -0,0 +1,218 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component } from '@angular/core';
import { IonicPage } from 'ionic-angular';
import { CoreCourse, CoreCourseProvider } from '@core/course/providers/course';
import { CoreCourses } from '@core/courses/providers/courses';
import { CoreArray } from '@singletons/array';
import { CoreCourseModulePrefetch } from '@core/course/providers/module-prefetch-delegate';
import { CoreConstants } from '@core/constants';
import { CoreDomUtils } from '@providers/utils/dom';
import { Translate } from '@singletons/core.singletons';
import { CoreEvents, CoreEventsProvider, CoreEventObserver } from '@providers/events';
import { CoreCourseHelper } from '@core/course/providers/helper';
/**
* Core course data.
*/
interface Course {
id: number;
displayname: string;
}
/**
* Downloaded course data.
*/
interface DownloadedCourse extends Course {
totalSize: number;
isDownloading: boolean;
}
/**
* Page that displays downloaded courses and allows the user to delete them.
*/
@IonicPage({ segment: 'addon-storagemanager-courses-storage' })
@Component({
selector: 'page-addon-storagemanager-courses-storage',
templateUrl: 'courses-storage.html',
})
export class AddonStorageManagerCoursesStoragePage {
userCourses: Course[] = [];
downloadedCourses: DownloadedCourse[] = [];
completelyDownloadedCourses: DownloadedCourse[] = [];
totalSize = 0;
loaded = false;
courseStatusObserver: CoreEventObserver;
/**
* View loaded.
*/
async ionViewDidLoad(): Promise<void> {
this.userCourses = await CoreCourses.instance.getUserCourses();
this.courseStatusObserver = CoreEvents.instance.on(
CoreEventsProvider.COURSE_STATUS_CHANGED,
({ courseId, status }) => this.onCourseUpdated(courseId, status),
);
const downloadedCourseIds = await CoreCourse.instance.getDownloadedCourseIds();
const downloadedCourses = await Promise.all(
this.userCourses
.filter((course) => downloadedCourseIds.indexOf(course.id) !== -1)
.map((course) => this.getDownloadedCourse(course)),
);
this.setDownloadedCourses(downloadedCourses);
this.loaded = true;
}
/**
* Component destroyed.
*/
ngOnDestroy(): void {
this.courseStatusObserver && this.courseStatusObserver.off();
}
/**
* Delete all courses that have been downloaded.
*/
async deleteCompletelyDownloadedCourses(): Promise<void> {
try {
await CoreDomUtils.instance.showDeleteConfirm('core.course.confirmdeletemodulefiles');
} catch (error) {
if (!error.coreCanceled) {
throw error;
}
return;
}
const modal = CoreDomUtils.instance.showModalLoading();
const deletedCourseIds = this.completelyDownloadedCourses.map((course) => course.id);
try {
await Promise.all(deletedCourseIds.map((courseId) => CoreCourseHelper.instance.deleteCourseFiles(courseId)));
this.setDownloadedCourses(this.downloadedCourses.filter((course) => !CoreArray.contains(deletedCourseIds, course.id)));
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, Translate.instance.instant('core.errordeletefile'));
} finally {
modal.dismiss();
}
}
/**
* Delete course.
*
* @param course Course to delete.
*/
async deleteCourse(course: DownloadedCourse): Promise<void> {
try {
await CoreDomUtils.instance.showDeleteConfirm('core.course.confirmdeletemodulefiles');
} catch (error) {
if (!error.coreCanceled) {
throw error;
}
return;
}
const modal = CoreDomUtils.instance.showModalLoading();
try {
await CoreCourseHelper.instance.deleteCourseFiles(course.id);
this.setDownloadedCourses(CoreArray.withoutItem(this.downloadedCourses, course));
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, Translate.instance.instant('core.errordeletefile'));
} finally {
modal.dismiss();
}
}
/**
* Handle course updated event.
*
* @param courseId Updated course id.
*/
private async onCourseUpdated(courseId: number, status: string): Promise<void> {
if (courseId == CoreCourseProvider.ALL_COURSES_CLEARED) {
this.setDownloadedCourses([]);
return;
}
const course = this.downloadedCourses.find((course) => course.id === courseId);
if (!course) {
return;
}
course.isDownloading = status === CoreConstants.DOWNLOADING;
course.totalSize = await this.calculateDownloadedCourseSize(course.id);
this.setDownloadedCourses(this.downloadedCourses);
}
/**
* Set downloaded courses data.
*
* @param courses Courses info.
*/
private setDownloadedCourses(courses: DownloadedCourse[]): void {
this.downloadedCourses = courses;
this.completelyDownloadedCourses = courses.filter((course) => !course.isDownloading);
this.totalSize = this.downloadedCourses.reduce((totalSize, course) => totalSize + course.totalSize, 0);
}
/**
* Get downloaded course data.
*
* @param course Course.
* @return Course info.
*/
private async getDownloadedCourse(course: Course): Promise<DownloadedCourse> {
const totalSize = await this.calculateDownloadedCourseSize(course.id);
const status = await CoreCourse.instance.getCourseStatus(course.id);
return {
...course,
totalSize,
isDownloading: status === CoreConstants.DOWNLOADING,
};
}
/**
* Calculate the size of a downloaded course.
*
* @param courseId Downloaded course id.
* @return Promise to be resolved with the course size.
*/
private async calculateDownloadedCourseSize(courseId: number): Promise<number> {
const sections = await CoreCourse.instance.getSections(courseId);
const modules = CoreArray.flatten(sections.map((section) => section.modules));
const promisedModuleSizes = modules.map(async (module) => {
const size = await CoreCourseModulePrefetch.instance.getModuleDownloadedSize(module, courseId);
return isNaN(size) ? 0 : size;
});
const moduleSizes = await Promise.all(promisedModuleSizes);
return moduleSizes.reduce((totalSize, moduleSize) => totalSize + moduleSize, 0);
}
}

View File

@ -1014,6 +1014,7 @@
"addon.notifications.playsound": "Play sound",
"addon.notifications.therearentnotificationsyet": "There are no notifications.",
"addon.storagemanager.deletecourse": "Offload all course data",
"addon.storagemanager.deletecourses": "Offload all courses data",
"addon.storagemanager.deletedatafrom": "Offload data from {{name}}",
"addon.storagemanager.info": "Files stored on your device make the app work faster and enable the app to be used offline. You can safely offload files if you need to free up storage space.",
"addon.storagemanager.managestorage": "Manage storage",

View File

@ -29,6 +29,7 @@ import { CoreSitePluginsProvider } from '@core/siteplugins/providers/siteplugins
import { CoreCourseFormatDelegate } from './format-delegate';
import { CorePushNotificationsProvider } from '@core/pushnotifications/providers/pushnotifications';
import { CoreCoursesProvider } from '@core/courses/providers/courses';
import { makeSingleton } from '@singletons/core.singletons';
/**
* Service that provides some features regarding a course.
@ -334,6 +335,23 @@ export class CoreCourseProvider {
});
}
/**
* Obtain ids of downloaded courses.
*
* @param siteId Site id.
* @return Resolves with an array containing downloaded course ids.
*/
async getDownloadedCourseIds(siteId?: string): Promise<number[]> {
const site = await this.sitesProvider.getSite(siteId);
const entries = await site.getDb().getRecordsList(this.COURSE_STATUS_TABLE, 'status', [
CoreConstants.DOWNLOADED,
CoreConstants.DOWNLOADING,
CoreConstants.OUTDATED,
]);
return entries.map((entry) => entry.id);
}
/**
* Get a module from Moodle.
*
@ -1178,3 +1196,5 @@ export type CoreCourseModuleSummary = {
url?: string; // Url.
iconurl: string; // Iconurl.
};
export class CoreCourse extends makeSingleton(CoreCourseProvider) {}

View File

@ -41,6 +41,7 @@ import { CoreLoggerProvider } from '@providers/logger';
import * as moment from 'moment';
import { CoreFilterHelperProvider } from '@core/filter/providers/helper';
import { CoreArray } from '@singletons/array';
import { makeSingleton } from '@singletons/core.singletons';
/**
* Prefetch info of a module.
@ -1627,3 +1628,5 @@ export class CoreCourseHelperProvider {
}
}
export class CoreCourseHelper extends makeSingleton(CoreCourseHelperProvider) {}

View File

@ -28,6 +28,7 @@ import { Md5 } from 'ts-md5/dist/md5';
import { Subject, BehaviorSubject, Subscription } from 'rxjs';
import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
import { CoreFileHelperProvider } from '@providers/file-helper';
import { makeSingleton } from '@singletons/core.singletons';
/**
* Progress of downloading a list of modules.
@ -1467,3 +1468,5 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate {
}
}
}
export class CoreCourseModulePrefetch extends makeSingleton(CoreCourseModulePrefetchDelegate) {}

View File

@ -162,6 +162,16 @@ export class CoreCoursesCourseProgressComponent implements OnInit, OnDestroy {
* Delete the course.
*/
async deleteCourse(): Promise<void> {
try {
await this.domUtils.showDeleteConfirm('core.course.confirmdeletemodulefiles');
} catch (error) {
if (!error.coreCanceled) {
throw error;
}
return;
}
const modal = this.domUtils.showModalLoading();
try {

View File

@ -10,6 +10,8 @@
<ion-icon name="search"></ion-icon>
</button>
<core-context-menu>
<core-context-menu-item *ngIf="(downloadCourseEnabled || downloadCoursesEnabled)" [priority]="500" [content]="'addon.storagemanager.managestorage' | translate" (action)="manageCoursesStorage()" iconAction="cube"></core-context-menu-item>
<!-- Action for dashboard and site home. -->
<core-context-menu-item *ngIf="(siteHomeEnabled || dashboardEnabled) && (downloadCourseEnabled || downloadCoursesEnabled)" [priority]="1000" [content]="'core.settings.showdownloadoptions' | translate" (action)="toggleDownload()" [iconAction]="downloadEnabledIcon"></core-context-menu-item>

View File

@ -122,6 +122,13 @@ export class CoreCoursesDashboardPage implements OnDestroy {
this.tabsComponent && this.tabsComponent.ionViewDidLeave();
}
/**
* Open page to manage courses storage.
*/
manageCoursesStorage(): void {
this.navCtrl.push('AddonStorageManagerCoursesStoragePage');
}
/**
* Go to search courses.
*/

View File

@ -17,6 +17,7 @@ import { CoreEventsProvider } from '@providers/events';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreSite } from '@classes/site';
import { makeSingleton } from '@singletons/core.singletons';
/**
* Data sent to the EVENT_MY_COURSES_UPDATED.
@ -1152,3 +1153,5 @@ export class CoreCoursesProvider {
});
}
}
export class CoreCourses extends makeSingleton(CoreCoursesProvider) {}

View File

@ -17,6 +17,17 @@
*/
export class CoreArray {
/**
* Check whether an array contains an item.
*
* @param arr Array.
* @param item Item.
* @return Whether item is within the array.
*/
static contains<T>(arr: T[], item: T): boolean {
return arr.indexOf(item) !== -1;
}
/**
* Flatten the first dimension of a multi-dimensional array.
*
@ -33,4 +44,22 @@ export class CoreArray {
return [].concat(...arr);
}
/**
* Obtain a new array without the specified item.
*
* @param arr Array.
* @param item Item to remove.
* @return Array without the specified item.
*/
static withoutItem<T>(arr: T[], item: T): T[] {
const newArray = [...arr];
const index = arr.indexOf(item);
if (index !== -1) {
newArray.splice(index, 1);
}
return newArray;
}
}