diff --git a/src/addon/storagemanager/lang/en.json b/src/addon/storagemanager/lang/en.json index a3491b8b9..8efc60a30 100644 --- a/src/addon/storagemanager/lang/en.json +++ b/src/addon/storagemanager/lang/en.json @@ -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", diff --git a/src/addon/storagemanager/pages/course-storage/course-storage.ts b/src/addon/storagemanager/pages/course-storage/course-storage.ts index ff9196403..6bdeecc52 100644 --- a/src/addon/storagemanager/pages/course-storage/course-storage.ts +++ b/src/addon/storagemanager/pages/course-storage/course-storage.ts @@ -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 { + 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 { + 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 { + if (module.totalSize === 0) { + return; } + + try { + await this.domUtils.showDeleteConfirm('core.course.confirmdeletemodulefiles'); + } catch (error) { + if (!error.coreCanceled) { + throw error; + } + + return; + } + + this.deleteModules([module]); } /** diff --git a/src/addon/storagemanager/pages/courses-storage/courses-storage.html b/src/addon/storagemanager/pages/courses-storage/courses-storage.html new file mode 100644 index 000000000..937ddd2be --- /dev/null +++ b/src/addon/storagemanager/pages/courses-storage/courses-storage.html @@ -0,0 +1,40 @@ + + + {{ 'addon.storagemanager.managestorage' | translate }} + + + + + + +

{{ 'core.courses.courses' | translate }}

+

{{ 'addon.storagemanager.info' | translate }}

+ + +

{{ 'addon.storagemanager.storageused' | translate }}

+
+

{{ totalSize | coreBytesToSize }}

+
+ +
+
+
+ + + +

{{ course.displayname }}

+

{{ 'core.downloading' | translate }}

+

+ + {{ course.totalSize | coreBytesToSize }} +

+ +
+
+
+
+
diff --git a/src/addon/storagemanager/pages/courses-storage/courses-storage.module.ts b/src/addon/storagemanager/pages/courses-storage/courses-storage.module.ts new file mode 100644 index 000000000..95a210991 --- /dev/null +++ b/src/addon/storagemanager/pages/courses-storage/courses-storage.module.ts @@ -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 { +} diff --git a/src/addon/storagemanager/pages/courses-storage/courses-storage.scss b/src/addon/storagemanager/pages/courses-storage/courses-storage.scss new file mode 100644 index 000000000..b26c3fb94 --- /dev/null +++ b/src/addon/storagemanager/pages/courses-storage/courses-storage.scss @@ -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; + } + + } + +} diff --git a/src/addon/storagemanager/pages/courses-storage/courses-storage.ts b/src/addon/storagemanager/pages/courses-storage/courses-storage.ts new file mode 100644 index 000000000..05b5aa709 --- /dev/null +++ b/src/addon/storagemanager/pages/courses-storage/courses-storage.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } + +} diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 290fe79f4..b434b611b 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -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", diff --git a/src/core/course/providers/course.ts b/src/core/course/providers/course.ts index 1facbf99f..c6dc1c576 100644 --- a/src/core/course/providers/course.ts +++ b/src/core/course/providers/course.ts @@ -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 { + 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) {} diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts index 76dd85242..25d5137eb 100644 --- a/src/core/course/providers/helper.ts +++ b/src/core/course/providers/helper.ts @@ -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) {} diff --git a/src/core/course/providers/module-prefetch-delegate.ts b/src/core/course/providers/module-prefetch-delegate.ts index 50fbdc5db..38a5cc3d0 100644 --- a/src/core/course/providers/module-prefetch-delegate.ts +++ b/src/core/course/providers/module-prefetch-delegate.ts @@ -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) {} diff --git a/src/core/courses/components/course-progress/course-progress.ts b/src/core/courses/components/course-progress/course-progress.ts index c0b32c4b7..88923c044 100644 --- a/src/core/courses/components/course-progress/course-progress.ts +++ b/src/core/courses/components/course-progress/course-progress.ts @@ -162,6 +162,16 @@ export class CoreCoursesCourseProgressComponent implements OnInit, OnDestroy { * Delete the course. */ async deleteCourse(): Promise { + try { + await this.domUtils.showDeleteConfirm('core.course.confirmdeletemodulefiles'); + } catch (error) { + if (!error.coreCanceled) { + throw error; + } + + return; + } + const modal = this.domUtils.showModalLoading(); try { diff --git a/src/core/courses/pages/dashboard/dashboard.html b/src/core/courses/pages/dashboard/dashboard.html index 4ad0dcd61..b4d21e8bd 100644 --- a/src/core/courses/pages/dashboard/dashboard.html +++ b/src/core/courses/pages/dashboard/dashboard.html @@ -10,6 +10,8 @@ + + diff --git a/src/core/courses/pages/dashboard/dashboard.ts b/src/core/courses/pages/dashboard/dashboard.ts index 99ade84ca..49f2924bb 100644 --- a/src/core/courses/pages/dashboard/dashboard.ts +++ b/src/core/courses/pages/dashboard/dashboard.ts @@ -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. */ diff --git a/src/core/courses/providers/courses.ts b/src/core/courses/providers/courses.ts index 2ef00a33c..fc56eb385 100644 --- a/src/core/courses/providers/courses.ts +++ b/src/core/courses/providers/courses.ts @@ -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) {} diff --git a/src/singletons/array.ts b/src/singletons/array.ts index 6411c9f9a..a35ca1450 100644 --- a/src/singletons/array.ts +++ b/src/singletons/array.ts @@ -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(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(arr: T[], item: T): T[] { + const newArray = [...arr]; + const index = arr.indexOf(item); + + if (index !== -1) { + newArray.splice(index, 1); + } + + return newArray; + } + }