From 704e3796a0839d389995f14617493c4817c30208 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 7 Dec 2017 10:39:11 +0100 Subject: [PATCH 01/24] MOBILE-2302 mainmenu: Implement delegate and Main Menu page --- src/core/login/providers/helper.ts | 81 ++++---- src/core/mainmenu/lang/ar.json | 8 + src/core/mainmenu/lang/bg.json | 8 + src/core/mainmenu/lang/ca.json | 9 + src/core/mainmenu/lang/cs.json | 9 + src/core/mainmenu/lang/da.json | 9 + src/core/mainmenu/lang/de-du.json | 9 + src/core/mainmenu/lang/de.json | 9 + src/core/mainmenu/lang/el.json | 9 + src/core/mainmenu/lang/en.json | 9 + src/core/mainmenu/lang/es-mx.json | 9 + src/core/mainmenu/lang/es.json | 9 + src/core/mainmenu/lang/eu.json | 9 + src/core/mainmenu/lang/fa.json | 8 + src/core/mainmenu/lang/fi.json | 9 + src/core/mainmenu/lang/fr.json | 9 + src/core/mainmenu/lang/he.json | 9 + src/core/mainmenu/lang/hr.json | 8 + src/core/mainmenu/lang/hu.json | 7 + src/core/mainmenu/lang/it.json | 9 + src/core/mainmenu/lang/ja.json | 8 + src/core/mainmenu/lang/lt.json | 9 + src/core/mainmenu/lang/mr.json | 9 + src/core/mainmenu/lang/nl.json | 9 + src/core/mainmenu/lang/no.json | 5 + src/core/mainmenu/lang/pl.json | 7 + src/core/mainmenu/lang/pt-br.json | 9 + src/core/mainmenu/lang/pt.json | 9 + src/core/mainmenu/lang/ro.json | 9 + src/core/mainmenu/lang/ru.json | 8 + src/core/mainmenu/lang/sr-cr.json | 9 + src/core/mainmenu/lang/sr-lt.json | 9 + src/core/mainmenu/lang/sv.json | 8 + src/core/mainmenu/lang/tr.json | 7 + src/core/mainmenu/lang/uk.json | 9 + src/core/mainmenu/lang/zh-cn.json | 7 + src/core/mainmenu/lang/zh-tw.json | 9 + src/core/mainmenu/mainmenu.module.ts | 27 +++ src/core/mainmenu/pages/menu/menu.html | 3 + src/core/mainmenu/pages/menu/menu.module.ts | 31 +++ src/core/mainmenu/pages/menu/menu.scss | 3 + src/core/mainmenu/pages/menu/menu.ts | 72 +++++++ src/core/mainmenu/providers/delegate.ts | 202 ++++++++++++++++++++ src/lang/en.json | 1 + 44 files changed, 687 insertions(+), 38 deletions(-) create mode 100644 src/core/mainmenu/lang/ar.json create mode 100644 src/core/mainmenu/lang/bg.json create mode 100644 src/core/mainmenu/lang/ca.json create mode 100644 src/core/mainmenu/lang/cs.json create mode 100644 src/core/mainmenu/lang/da.json create mode 100644 src/core/mainmenu/lang/de-du.json create mode 100644 src/core/mainmenu/lang/de.json create mode 100644 src/core/mainmenu/lang/el.json create mode 100644 src/core/mainmenu/lang/en.json create mode 100644 src/core/mainmenu/lang/es-mx.json create mode 100644 src/core/mainmenu/lang/es.json create mode 100644 src/core/mainmenu/lang/eu.json create mode 100644 src/core/mainmenu/lang/fa.json create mode 100644 src/core/mainmenu/lang/fi.json create mode 100644 src/core/mainmenu/lang/fr.json create mode 100644 src/core/mainmenu/lang/he.json create mode 100644 src/core/mainmenu/lang/hr.json create mode 100644 src/core/mainmenu/lang/hu.json create mode 100644 src/core/mainmenu/lang/it.json create mode 100644 src/core/mainmenu/lang/ja.json create mode 100644 src/core/mainmenu/lang/lt.json create mode 100644 src/core/mainmenu/lang/mr.json create mode 100644 src/core/mainmenu/lang/nl.json create mode 100644 src/core/mainmenu/lang/no.json create mode 100644 src/core/mainmenu/lang/pl.json create mode 100644 src/core/mainmenu/lang/pt-br.json create mode 100644 src/core/mainmenu/lang/pt.json create mode 100644 src/core/mainmenu/lang/ro.json create mode 100644 src/core/mainmenu/lang/ru.json create mode 100644 src/core/mainmenu/lang/sr-cr.json create mode 100644 src/core/mainmenu/lang/sr-lt.json create mode 100644 src/core/mainmenu/lang/sv.json create mode 100644 src/core/mainmenu/lang/tr.json create mode 100644 src/core/mainmenu/lang/uk.json create mode 100644 src/core/mainmenu/lang/zh-cn.json create mode 100644 src/core/mainmenu/lang/zh-tw.json create mode 100644 src/core/mainmenu/mainmenu.module.ts create mode 100644 src/core/mainmenu/pages/menu/menu.html create mode 100644 src/core/mainmenu/pages/menu/menu.module.ts create mode 100644 src/core/mainmenu/pages/menu/menu.scss create mode 100644 src/core/mainmenu/pages/menu/menu.ts create mode 100644 src/core/mainmenu/providers/delegate.ts diff --git a/src/core/login/providers/helper.ts b/src/core/login/providers/helper.ts index 2a61eaa71..d6baa15d7 100644 --- a/src/core/login/providers/helper.ts +++ b/src/core/login/providers/helper.ts @@ -379,49 +379,54 @@ export class CoreLoginHelperProvider { * @return {Promise} Promise resolved when done. */ goToSiteInitialPage(navCtrl: NavController, setRoot?: boolean) : Promise { - return this.isMyOverviewEnabled().then((myOverview) => { - let myCourses = !myOverview && this.isMyCoursesEnabled(), - site = this.sitesProvider.getCurrentSite(), - promise; + if (setRoot) { + return navCtrl.setRoot('CoreMainMenuPage', {}, {animate: false}); + } else { + return navCtrl.push('CoreMainMenuPage'); + } + // return this.isMyOverviewEnabled().then((myOverview) => { + // let myCourses = !myOverview && this.isMyCoursesEnabled(), + // site = this.sitesProvider.getCurrentSite(), + // promise; - if (!site) { - return Promise.reject(null); - } + // if (!site) { + // return Promise.reject(null); + // } - // Check if frontpage is needed to be shown. (If configured or if any of the other avalaible). - if ((site.getInfo() && site.getInfo().userhomepage === 0) || (!myCourses && !myOverview)) { - promise = this.isFrontpageEnabled(); - } else { - promise = Promise.resolve(false); - } + // // Check if frontpage is needed to be shown. (If configured or if any of the other avalaible). + // if ((site.getInfo() && site.getInfo().userhomepage === 0) || (!myCourses && !myOverview)) { + // promise = this.isFrontpageEnabled(); + // } else { + // promise = Promise.resolve(false); + // } - return promise.then((frontpage) => { - // Check avalaibility in priority order. - let pageName, - params; + // return promise.then((frontpage) => { + // // Check avalaibility in priority order. + // let pageName, + // params; - // @todo Use real pages names when they are implemented. - if (frontpage) { - pageName = 'Frontpage'; - } else if (myOverview) { - pageName = 'MyOverview'; - } else if (myCourses) { - pageName = 'MyCourses'; - } else { - // Anything else available, go to the user profile. - pageName = 'User'; - params = { - userId: site.getUserId() - }; - } + // // @todo Use real pages names when they are implemented. + // if (frontpage) { + // pageName = 'Frontpage'; + // } else if (myOverview) { + // pageName = 'MyOverview'; + // } else if (myCourses) { + // pageName = 'MyCourses'; + // } else { + // // Anything else available, go to the user profile. + // pageName = 'User'; + // params = { + // userId: site.getUserId() + // }; + // } - if (setRoot) { - return navCtrl.setRoot(pageName, params, {animate: false}); - } else { - return navCtrl.push(pageName, params); - } - }); - }); + // if (setRoot) { + // return navCtrl.setRoot(pageName, params, {animate: false}); + // } else { + // return navCtrl.push(pageName, params); + // } + // }); + // }); } /** diff --git a/src/core/mainmenu/lang/ar.json b/src/core/mainmenu/lang/ar.json new file mode 100644 index 000000000..79a0f6adb --- /dev/null +++ b/src/core/mainmenu/lang/ar.json @@ -0,0 +1,8 @@ +{ + "appsettings": "إعدادات التطبيق", + "changesite": "الخروج", + "help": "مساعدة", + "logout": "خروج", + "mycourses": "مقرراتي الدراسية", + "website": "الموقع" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/bg.json b/src/core/mainmenu/lang/bg.json new file mode 100644 index 000000000..3ccb973aa --- /dev/null +++ b/src/core/mainmenu/lang/bg.json @@ -0,0 +1,8 @@ +{ + "appsettings": "Настройки на приложението", + "changesite": "Изход", + "help": "Помощ", + "logout": "Изход", + "mycourses": "Моите курсове", + "website": "Уебсайт" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/ca.json b/src/core/mainmenu/lang/ca.json new file mode 100644 index 000000000..d2e61d92b --- /dev/null +++ b/src/core/mainmenu/lang/ca.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Paràmetres de l'aplicació", + "changesite": "Canvia de lloc", + "help": "Ajuda", + "logout": "Surt", + "mycourses": "Els meus cursos", + "togglemenu": "Canvia menú", + "website": "Lloc web" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/cs.json b/src/core/mainmenu/lang/cs.json new file mode 100644 index 000000000..68c7fe8de --- /dev/null +++ b/src/core/mainmenu/lang/cs.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Nastavení aplikace", + "changesite": "Změnit stránky", + "help": "Pomoc", + "logout": "Odhlásit se", + "mycourses": "Moje kurzy", + "togglemenu": "Přepnout nabídku", + "website": "Webová stránka" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/da.json b/src/core/mainmenu/lang/da.json new file mode 100644 index 000000000..9384a37c6 --- /dev/null +++ b/src/core/mainmenu/lang/da.json @@ -0,0 +1,9 @@ +{ + "appsettings": "App-indstillinger", + "changesite": "Skift side", + "help": "Hjælp", + "logout": "Log ud", + "mycourses": "Mine kurser", + "togglemenu": "Skift menu", + "website": "Websted" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/de-du.json b/src/core/mainmenu/lang/de-du.json new file mode 100644 index 000000000..4f3285f04 --- /dev/null +++ b/src/core/mainmenu/lang/de-du.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Einstellungen", + "changesite": "Website wechseln", + "help": "Hilfe", + "logout": "Abmelden", + "mycourses": "Meine Kurse", + "togglemenu": "Menü umschalten", + "website": "Website im Browser" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/de.json b/src/core/mainmenu/lang/de.json new file mode 100644 index 000000000..4f3285f04 --- /dev/null +++ b/src/core/mainmenu/lang/de.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Einstellungen", + "changesite": "Website wechseln", + "help": "Hilfe", + "logout": "Abmelden", + "mycourses": "Meine Kurse", + "togglemenu": "Menü umschalten", + "website": "Website im Browser" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/el.json b/src/core/mainmenu/lang/el.json new file mode 100644 index 000000000..114771adb --- /dev/null +++ b/src/core/mainmenu/lang/el.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Ρυθμίσεις εφαρμογής", + "changesite": "Αλλαγή ιστότοπου", + "help": "Βοήθεια", + "logout": "Έξοδος", + "mycourses": "Τα μαθήματά μου", + "togglemenu": "Αλλαγή μενού", + "website": "Ιστότοπος" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/en.json b/src/core/mainmenu/lang/en.json new file mode 100644 index 000000000..dfa7b11f4 --- /dev/null +++ b/src/core/mainmenu/lang/en.json @@ -0,0 +1,9 @@ +{ + "appsettings": "App settings", + "changesite": "Change site", + "help": "Help", + "logout": "Log out", + "mycourses": "My courses", + "togglemenu": "Toggle menu", + "website": "Website" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/es-mx.json b/src/core/mainmenu/lang/es-mx.json new file mode 100644 index 000000000..fb36a0997 --- /dev/null +++ b/src/core/mainmenu/lang/es-mx.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Configuraciones del App", + "changesite": "Cambiar sitio", + "help": "Ayuda", + "logout": "Salir", + "mycourses": "Mis cursos", + "togglemenu": "Alternar menú", + "website": "Página web" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/es.json b/src/core/mainmenu/lang/es.json new file mode 100644 index 000000000..e826092d7 --- /dev/null +++ b/src/core/mainmenu/lang/es.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Configuración de la aplicación", + "changesite": "Cambiar de sitio", + "help": "Ayuda", + "logout": "Salir", + "mycourses": "Mis cursos", + "togglemenu": "Cambiar menú", + "website": "Página web" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/eu.json b/src/core/mainmenu/lang/eu.json new file mode 100644 index 000000000..c5757719d --- /dev/null +++ b/src/core/mainmenu/lang/eu.json @@ -0,0 +1,9 @@ +{ + "appsettings": "App-aren ezarpenak", + "changesite": "Aldatu gunea", + "help": "Laguntza", + "logout": "Saioa amaitu", + "mycourses": "Nire ikastaroak", + "togglemenu": "Aldatu menua", + "website": "Webgunea" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/fa.json b/src/core/mainmenu/lang/fa.json new file mode 100644 index 000000000..34bdcc701 --- /dev/null +++ b/src/core/mainmenu/lang/fa.json @@ -0,0 +1,8 @@ +{ + "appsettings": "تنظیمات برنامه", + "changesite": "خروج", + "help": "راهنمایی", + "logout": "خروج از سایت", + "mycourses": "درس‌های من", + "website": "پایگاه اینترنتی" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/fi.json b/src/core/mainmenu/lang/fi.json new file mode 100644 index 000000000..9842996fb --- /dev/null +++ b/src/core/mainmenu/lang/fi.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Mobiilisovelluksen asetukset", + "changesite": "Vaihda sivustoa", + "help": "Ohje", + "logout": "Kirjaudu ulos", + "mycourses": "Omat kurssini", + "togglemenu": "Valikko päälle/pois", + "website": "Www-sivusto" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/fr.json b/src/core/mainmenu/lang/fr.json new file mode 100644 index 000000000..665a741dd --- /dev/null +++ b/src/core/mainmenu/lang/fr.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Réglages de l'app", + "changesite": "Changer de plateforme", + "help": "Aide", + "logout": "Déconnexion", + "mycourses": "Mes cours", + "togglemenu": "Menu", + "website": "Site web" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/he.json b/src/core/mainmenu/lang/he.json new file mode 100644 index 000000000..df2620a01 --- /dev/null +++ b/src/core/mainmenu/lang/he.json @@ -0,0 +1,9 @@ +{ + "appsettings": "הגדרות יישומון", + "changesite": "התנתק", + "help": "עזרה", + "logout": "התנתקות", + "mycourses": "הקורסים שלי", + "togglemenu": "החלפת מצב תפריט", + "website": "אתר אינטרנט" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/hr.json b/src/core/mainmenu/lang/hr.json new file mode 100644 index 000000000..e9d73415c --- /dev/null +++ b/src/core/mainmenu/lang/hr.json @@ -0,0 +1,8 @@ +{ + "appsettings": "Postavke aplikacije", + "changesite": "Promijeni poslužitelj", + "help": "Pomoć", + "logout": "Odjava", + "mycourses": "Moji e-kolegiji", + "website": "Web" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/hu.json b/src/core/mainmenu/lang/hu.json new file mode 100644 index 000000000..c7739b3a4 --- /dev/null +++ b/src/core/mainmenu/lang/hu.json @@ -0,0 +1,7 @@ +{ + "changesite": "Kilépés", + "help": "Súgó", + "logout": "Kilépés", + "mycourses": "Kurzusaim", + "website": "Weboldal" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/it.json b/src/core/mainmenu/lang/it.json new file mode 100644 index 000000000..9e368b1ef --- /dev/null +++ b/src/core/mainmenu/lang/it.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Impostazioni app", + "changesite": "Cambia sito", + "help": "Aiuto", + "logout": "Esci", + "mycourses": "I miei corsi", + "togglemenu": "Attiva/disattiva menu", + "website": "Sito web" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/ja.json b/src/core/mainmenu/lang/ja.json new file mode 100644 index 000000000..c91ac1393 --- /dev/null +++ b/src/core/mainmenu/lang/ja.json @@ -0,0 +1,8 @@ +{ + "appsettings": "アプリ設定", + "changesite": "ログアウト", + "help": "ヘルプ", + "logout": "ログアウト", + "mycourses": "マイコース", + "website": "ウェブサイト" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/lt.json b/src/core/mainmenu/lang/lt.json new file mode 100644 index 000000000..4f3f66ea9 --- /dev/null +++ b/src/core/mainmenu/lang/lt.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Programėlės nustatymai", + "changesite": "Pakeisti svetainę", + "help": "Žinynas", + "logout": "Atsijungti", + "mycourses": "Mano kursai", + "togglemenu": "Perjungti", + "website": "Interneto svetainė" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/mr.json b/src/core/mainmenu/lang/mr.json new file mode 100644 index 000000000..9f0dbf261 --- /dev/null +++ b/src/core/mainmenu/lang/mr.json @@ -0,0 +1,9 @@ +{ + "appsettings": "अॅप सेटिंग्ज", + "changesite": "साइट बदला", + "help": "मदत", + "logout": "लॉग-आउट", + "mycourses": "माझे कोर्सेस", + "togglemenu": "मेनू बदला", + "website": "वेबसाइट" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/nl.json b/src/core/mainmenu/lang/nl.json new file mode 100644 index 000000000..7caa7f904 --- /dev/null +++ b/src/core/mainmenu/lang/nl.json @@ -0,0 +1,9 @@ +{ + "appsettings": "App instellingen", + "changesite": "Naar andere site", + "help": "Help", + "logout": "Uitloggen", + "mycourses": "Mijn cursussen", + "togglemenu": "Menu schakelen", + "website": "Website" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/no.json b/src/core/mainmenu/lang/no.json new file mode 100644 index 000000000..e049b9cd8 --- /dev/null +++ b/src/core/mainmenu/lang/no.json @@ -0,0 +1,5 @@ +{ + "help": "Hjelp", + "logout": "Logg ut", + "mycourses": "Mine kurs" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/pl.json b/src/core/mainmenu/lang/pl.json new file mode 100644 index 000000000..db5b40a15 --- /dev/null +++ b/src/core/mainmenu/lang/pl.json @@ -0,0 +1,7 @@ +{ + "changesite": "Wyloguj", + "help": "Pomoc", + "logout": "Wyloguj", + "mycourses": "Moje kursy", + "website": "Witryna" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/pt-br.json b/src/core/mainmenu/lang/pt-br.json new file mode 100644 index 000000000..fd3d93a17 --- /dev/null +++ b/src/core/mainmenu/lang/pt-br.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Configurações do aplicativo", + "changesite": "Mudar site", + "help": "Ajuda", + "logout": "Sair", + "mycourses": "Meus cursos", + "togglemenu": "Alternar menu", + "website": "Site" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/pt.json b/src/core/mainmenu/lang/pt.json new file mode 100644 index 000000000..479ba8e4f --- /dev/null +++ b/src/core/mainmenu/lang/pt.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Configurações da aplicação", + "changesite": "Mudar de site", + "help": "Ajuda", + "logout": "Sair", + "mycourses": "As minhas disciplinas", + "togglemenu": "Alternar menu", + "website": "Site" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/ro.json b/src/core/mainmenu/lang/ro.json new file mode 100644 index 000000000..1c5f17d9e --- /dev/null +++ b/src/core/mainmenu/lang/ro.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Setările aplicației", + "changesite": "Schimbați siteul", + "help": "Ajutor", + "logout": "Ieşire", + "mycourses": "Cursurile mele", + "togglemenu": "Meniul pentru comutare", + "website": "Website" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/ru.json b/src/core/mainmenu/lang/ru.json new file mode 100644 index 000000000..541a10d32 --- /dev/null +++ b/src/core/mainmenu/lang/ru.json @@ -0,0 +1,8 @@ +{ + "appsettings": "Настройки приложения", + "changesite": "Выход", + "help": "Справка", + "logout": "Выход", + "mycourses": "Мои курсы", + "website": "Сайт" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/sr-cr.json b/src/core/mainmenu/lang/sr-cr.json new file mode 100644 index 000000000..8323e0aa5 --- /dev/null +++ b/src/core/mainmenu/lang/sr-cr.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Подешавања апликације", + "changesite": "Промени сајт", + "help": "Помоћ", + "logout": "Одјава", + "mycourses": "Моји курсеви", + "togglemenu": "Укључи/искључи мени", + "website": "Веб сајт" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/sr-lt.json b/src/core/mainmenu/lang/sr-lt.json new file mode 100644 index 000000000..aa6e18a37 --- /dev/null +++ b/src/core/mainmenu/lang/sr-lt.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Podešavanja aplikacije", + "changesite": "Promeni sajt", + "help": "Pomoć", + "logout": "Odjava", + "mycourses": "Moji kursevi", + "togglemenu": "Uključi/isključi meni", + "website": "Veb sajt" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/sv.json b/src/core/mainmenu/lang/sv.json new file mode 100644 index 000000000..c1787a309 --- /dev/null +++ b/src/core/mainmenu/lang/sv.json @@ -0,0 +1,8 @@ +{ + "appsettings": "App inställningar", + "changesite": "Byt webbsida", + "help": "Hjälp", + "logout": "Logga ut", + "mycourses": "Mina kurser", + "website": "Webbsida" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/tr.json b/src/core/mainmenu/lang/tr.json new file mode 100644 index 000000000..ace500a4d --- /dev/null +++ b/src/core/mainmenu/lang/tr.json @@ -0,0 +1,7 @@ +{ + "changesite": "Çıkış", + "help": "Yardım", + "logout": "Çıkış yap", + "mycourses": "Derslerim", + "website": "Websitesi" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/uk.json b/src/core/mainmenu/lang/uk.json new file mode 100644 index 000000000..21328fec9 --- /dev/null +++ b/src/core/mainmenu/lang/uk.json @@ -0,0 +1,9 @@ +{ + "appsettings": "Налаштування додатку", + "changesite": "Змінити сайт", + "help": "Допомога", + "logout": "Вихід", + "mycourses": "Мої курси", + "togglemenu": "Перемикання меню", + "website": "Веб-сайт" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/zh-cn.json b/src/core/mainmenu/lang/zh-cn.json new file mode 100644 index 000000000..ea6cd6e91 --- /dev/null +++ b/src/core/mainmenu/lang/zh-cn.json @@ -0,0 +1,7 @@ +{ + "changesite": "注销", + "help": "帮助", + "logout": "注销", + "mycourses": "我的课程", + "website": "网站" +} \ No newline at end of file diff --git a/src/core/mainmenu/lang/zh-tw.json b/src/core/mainmenu/lang/zh-tw.json new file mode 100644 index 000000000..412f26164 --- /dev/null +++ b/src/core/mainmenu/lang/zh-tw.json @@ -0,0 +1,9 @@ +{ + "appsettings": "應用程式設定", + "changesite": "更換網站", + "help": "協助", + "logout": "更換網站", + "mycourses": "我的課程", + "togglemenu": "切換功能選單", + "website": "網站" +} \ No newline at end of file diff --git a/src/core/mainmenu/mainmenu.module.ts b/src/core/mainmenu/mainmenu.module.ts new file mode 100644 index 000000000..7cee1594c --- /dev/null +++ b/src/core/mainmenu/mainmenu.module.ts @@ -0,0 +1,27 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreMainMenuDelegate } from './providers/delegate'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + CoreMainMenuDelegate, + ] +}) +export class CoreMainMenuModule {} diff --git a/src/core/mainmenu/pages/menu/menu.html b/src/core/mainmenu/pages/menu/menu.html new file mode 100644 index 000000000..ec3f330e1 --- /dev/null +++ b/src/core/mainmenu/pages/menu/menu.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/core/mainmenu/pages/menu/menu.module.ts b/src/core/mainmenu/pages/menu/menu.module.ts new file mode 100644 index 000000000..2f8f96523 --- /dev/null +++ b/src/core/mainmenu/pages/menu/menu.module.ts @@ -0,0 +1,31 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreMainMenuPage } from './menu'; +import { CoreMainMenuModule } from '../../mainmenu.module'; + +@NgModule({ + declarations: [ + CoreMainMenuPage, + ], + imports: [ + CoreMainMenuModule, + IonicPageModule.forChild(CoreMainMenuPage), + TranslateModule.forChild() + ], +}) +export class CoreMainMenuPageModule {} diff --git a/src/core/mainmenu/pages/menu/menu.scss b/src/core/mainmenu/pages/menu/menu.scss new file mode 100644 index 000000000..87c7bcce9 --- /dev/null +++ b/src/core/mainmenu/pages/menu/menu.scss @@ -0,0 +1,3 @@ +page-core-mainmenu { + +} diff --git a/src/core/mainmenu/pages/menu/menu.ts b/src/core/mainmenu/pages/menu/menu.ts new file mode 100644 index 000000000..8ed726590 --- /dev/null +++ b/src/core/mainmenu/pages/menu/menu.ts @@ -0,0 +1,72 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, OnDestroy } from '@angular/core'; +import { IonicPage, NavController } from 'ionic-angular'; +import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreMainMenuDelegate, CoreMainMenuHandlerData } from '../../providers/delegate'; + +/** + * Page that displays the main menu of the app. + */ +@IonicPage() +@Component({ + selector: 'page-core-mainmenu', + templateUrl: 'menu.html', +}) +export class CoreMainMenuPage implements OnDestroy { + tabs: CoreMainMenuHandlerData[]; + loaded: boolean; + protected subscription; + protected moreTabData = { + page: 'CoreMainMenuMorePage', + title: 'core.more', + icon: 'more' + }; + protected logoutObserver; + + constructor(private menuDelegate: CoreMainMenuDelegate, private sitesProvider: CoreSitesProvider, + private navCtrl: NavController, eventsProvider: CoreEventsProvider) { + + // Go to sites page when user is logged out. + this.logoutObserver = eventsProvider.on(CoreEventsProvider.LOGOUT, () => { + this.navCtrl.setRoot('CoreLoginSitesPage'); + }); + } + + /** + * View loaded. + */ + ionViewDidLoad() { + if (!this.sitesProvider.isLoggedIn()) { + this.navCtrl.setRoot('CoreLoginSitesPage'); + return; + } + + this.subscription = this.menuDelegate.getHandlers().subscribe((handlers) => { + this.tabs = handlers.slice(0, 4); // Get first 4. + this.tabs.push(this.moreTabData); // Add "More" tab. + this.loaded = this.menuDelegate.areHandlersLoaded(); + }); + } + + /** + * Page destroyed. + */ + ngOnDestroy() { + this.subscription && this.subscription.unsubscribe(); + this.logoutObserver && this.logoutObserver.off(); + } +} diff --git a/src/core/mainmenu/providers/delegate.ts b/src/core/mainmenu/providers/delegate.ts new file mode 100644 index 000000000..613d562be --- /dev/null +++ b/src/core/mainmenu/providers/delegate.ts @@ -0,0 +1,202 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Injectable } from '@angular/core'; +import { CoreEventsProvider } from '../../../providers/events'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { Subject, BehaviorSubject } from 'rxjs'; + +export interface CoreMainMenuHandler { + name: string; // Name of the handler. + priority: number; // The highest priority is displayed first. + isEnabled(): boolean|Promise; // Whether or not the handler is enabled on a site level. + getDisplayData(): CoreMainMenuHandlerData; // Returns the data needed to render the handler. +}; + +export interface CoreMainMenuHandlerData { + page: string; // Name of the page. + title: string; // Title to display in the tab. + icon: string; // Name of the icon to display in the tab. + class?: string; // Class to add to the displayed handler. +}; + +/** + * Service to interact with plugins to be shown in the main menu. Provides functions to register a plugin + * and notify an update in the data. + */ +@Injectable() +export class CoreMainMenuDelegate { + protected logger; + protected handlers: {[s: string]: CoreMainMenuHandler} = {}; + protected enabledHandlers: {[s: string]: CoreMainMenuHandler} = {}; + protected loaded = false; + protected lastUpdateHandlersStart: number; + protected siteHandlers: Subject = new BehaviorSubject([]); + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider) { + this.logger = logger.getInstance('CoreMainMenuDelegate'); + + eventsProvider.on(CoreEventsProvider.LOGIN, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.SITE_UPDATED, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.REMOTE_ADDONS_LOADED, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.LOGOUT, this.clearSiteHandlers.bind(this)); + } + + /** + * Check if handlers are loaded. + * + * @return {boolean} True if handlers are loaded, false otherwise. + */ + areHandlersLoaded() : boolean { + return this.loaded; + } + + /** + * Clear current site handlers. Reserved for core use. + */ + protected clearSiteHandlers() { + this.loaded = false; + this.siteHandlers.next([]); + } + + /** + * Get the handlers for the current site. + * + * @return {Subject} An observable that will receive the handlers. + */ + getHandlers() : Subject { + return this.siteHandlers; + } + + /** + * Check if a time belongs to the last update handlers call. + * This is to handle the cases where updateHandlers don't finish in the same order as they're called. + * + * @param {number} time Time to check. + * @return {boolean} Whether it's the last call. + */ + isLastUpdateCall(time: number) : boolean { + if (!this.lastUpdateHandlersStart) { + return true; + } + return time == this.lastUpdateHandlersStart; + } + + /** + * Register a handler. + * + * @param {CoreInitHandler} handler The handler to register. + * @return {boolean} True if registered successfully, false otherwise. + */ + registerHandler(handler: CoreMainMenuHandler) : boolean { + if (typeof this.handlers[handler.name] !== 'undefined') { + this.logger.log(`Addon 'handler.name' already registered`); + return false; + } + this.logger.log(`Registered addon 'handler.name'`); + this.handlers[handler.name] = handler; + return true; + } + + /** + * Update the handler for the current site. + * + * @param {CoreInitHandler} handler The handler to check. + * @param {number} time Time this update process started. + * @return {Promise} Resolved when done. + */ + protected updateHandler(handler: CoreMainMenuHandler, time: number) : Promise { + let promise, + siteId = this.sitesProvider.getCurrentSiteId(), + currentSite = this.sitesProvider.getCurrentSite(); + + if (!this.sitesProvider.isLoggedIn()) { + promise = Promise.reject(null); + } else if (currentSite.isFeatureDisabled('$mmSideMenuDelegate_' + handler.name)) { + promise = Promise.resolve(false); + } else { + promise = Promise.resolve(handler.isEnabled()); + } + + // Checks if the handler is enabled. + return promise.catch(() => { + return false; + }).then((enabled: boolean) => { + // Verify that this call is the last one that was started. + // Check that site hasn't changed since the check started. + if (this.isLastUpdateCall(time) && this.sitesProvider.getCurrentSiteId() === siteId) { + if (enabled) { + this.enabledHandlers[handler.name] = handler; + } else { + delete this.enabledHandlers[handler.name]; + } + } + }); + } + + /** + * Update the handlers for the current site. + * + * @return {Promise} Resolved when done. + */ + protected updateHandlers() : Promise { + let promises = [], + now = Date.now(); + + this.logger.debug('Updating handlers for current site.'); + + this.lastUpdateHandlersStart = now; + + // Loop over all the handlers. + for (let name in this.handlers) { + promises.push(this.updateHandler(this.handlers[name], now)); + } + + return Promise.all(promises).then(() => { + return true; + }, () => { + // Never reject. + return true; + }).then(() => { + // Verify that this call is the last one that was started. + if (this.isLastUpdateCall(now)) { + let handlersData: any[] = []; + + for (let name in this.enabledHandlers) { + let handler = this.enabledHandlers[name], + data = handler.getDisplayData(); + + handlersData.push({ + data: data, + priority: handler.priority + }); + } + + // Sort them by priority. + handlersData.sort((a, b) => { + return b.priority - a.priority; + }); + + // Return only the display data. + let displayData = handlersData.map((item) => { + return item.data; + }); + + this.loaded = true; + this.siteHandlers.next(displayData); + } + }); + } +} diff --git a/src/lang/en.json b/src/lang/en.json index 3b9f8ba52..64a2104f4 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -133,6 +133,7 @@ "mod_url": "URL", "mod_wiki": "Wiki", "mod_workshop": "Workshop", + "more": "More", "mygroups": "My groups", "name": "Name", "nograde": "No grade", From c950d7dd40983d2671c97ac8b105e2084b4c9221 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 7 Dec 2017 13:29:42 +0100 Subject: [PATCH 02/24] MOBILE-2302 mainmenu: Implement More page --- src/classes/site.ts | 6 +- src/core/mainmenu/mainmenu.module.ts | 2 + src/core/mainmenu/pages/menu/menu.ts | 3 +- src/core/mainmenu/pages/more/more.html | 52 ++++++++ src/core/mainmenu/pages/more/more.module.ts | 35 +++++ src/core/mainmenu/pages/more/more.scss | 3 + src/core/mainmenu/pages/more/more.ts | 129 +++++++++++++++++++ src/core/mainmenu/providers/mainmenu.ts | 135 ++++++++++++++++++++ 8 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 src/core/mainmenu/pages/more/more.html create mode 100644 src/core/mainmenu/pages/more/more.module.ts create mode 100644 src/core/mainmenu/pages/more/more.scss create mode 100644 src/core/mainmenu/pages/more/more.ts create mode 100644 src/core/mainmenu/providers/mainmenu.ts diff --git a/src/classes/site.ts b/src/classes/site.ts index 9a2673b75..901e9d7e7 100644 --- a/src/classes/site.ts +++ b/src/classes/site.ts @@ -853,10 +853,10 @@ export class CoreSite { /** * Returns the URL to the documentation of the app, based on Moodle version and current language. * - * @param {string} [page] Docs page to go to. - * @return {Promise} Promise resolved with the Moodle docs URL. + * @param {string} [page] Docs page to go to. + * @return {Promise} Promise resolved with the Moodle docs URL. */ - getDocsUrl(page: string) : Promise { + getDocsUrl(page?: string) : Promise { const release = this.infos.release ? this.infos.release : undefined; return this.urlUtils.getDocsUrl(release, page); } diff --git a/src/core/mainmenu/mainmenu.module.ts b/src/core/mainmenu/mainmenu.module.ts index 7cee1594c..0a49f84b8 100644 --- a/src/core/mainmenu/mainmenu.module.ts +++ b/src/core/mainmenu/mainmenu.module.ts @@ -14,6 +14,7 @@ import { NgModule } from '@angular/core'; import { CoreMainMenuDelegate } from './providers/delegate'; +import { CoreMainMenuProvider } from './providers/mainmenu'; @NgModule({ declarations: [ @@ -22,6 +23,7 @@ import { CoreMainMenuDelegate } from './providers/delegate'; ], providers: [ CoreMainMenuDelegate, + CoreMainMenuProvider ] }) export class CoreMainMenuModule {} diff --git a/src/core/mainmenu/pages/menu/menu.ts b/src/core/mainmenu/pages/menu/menu.ts index 8ed726590..e9bab31e5 100644 --- a/src/core/mainmenu/pages/menu/menu.ts +++ b/src/core/mainmenu/pages/menu/menu.ts @@ -16,6 +16,7 @@ import { Component, OnDestroy } from '@angular/core'; import { IonicPage, NavController } from 'ionic-angular'; import { CoreEventsProvider } from '../../../../providers/events'; import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreMainMenuProvider } from '../../providers/mainmenu'; import { CoreMainMenuDelegate, CoreMainMenuHandlerData } from '../../providers/delegate'; /** @@ -56,7 +57,7 @@ export class CoreMainMenuPage implements OnDestroy { } this.subscription = this.menuDelegate.getHandlers().subscribe((handlers) => { - this.tabs = handlers.slice(0, 4); // Get first 4. + this.tabs = handlers.slice(0, CoreMainMenuProvider.NUM_MAIN_HANDLERS); // Get main handlers. this.tabs.push(this.moreTabData); // Add "More" tab. this.loaded = this.menuDelegate.areHandlersLoaded(); }); diff --git a/src/core/mainmenu/pages/more/more.html b/src/core/mainmenu/pages/more/more.html new file mode 100644 index 000000000..d1c02dc11 --- /dev/null +++ b/src/core/mainmenu/pages/more/more.html @@ -0,0 +1,52 @@ + + + {{ siteInfo.sitename }} + + + + + + + {{ 'core.pictureof' | translate:{$a: siteInfo.fullname} }} + +

{{siteInfo.fullname}}

+
+ + + + + + +

{{ handler.title | translate}}

+ + +
+ + + +

{{ 'core.mainmenu.website' | translate }}

+
+ + +

{{ 'core.mainmenu.help' | translate }}

+
+ + +

{{ 'core.mainmenu.appsettings' | translate }}

+
+ + +

{{ logoutLabel | translate }}

+
+
+
diff --git a/src/core/mainmenu/pages/more/more.module.ts b/src/core/mainmenu/pages/more/more.module.ts new file mode 100644 index 000000000..dd338a3ea --- /dev/null +++ b/src/core/mainmenu/pages/more/more.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreMainMenuMorePage } from './more'; +import { CoreMainMenuModule } from '../../mainmenu.module'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; + +@NgModule({ + declarations: [ + CoreMainMenuMorePage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CoreMainMenuModule, + IonicPageModule.forChild(CoreMainMenuMorePage), + TranslateModule.forChild() + ], +}) +export class CoreMainMenuPageModule {} diff --git a/src/core/mainmenu/pages/more/more.scss b/src/core/mainmenu/pages/more/more.scss new file mode 100644 index 000000000..ac9a4b40b --- /dev/null +++ b/src/core/mainmenu/pages/more/more.scss @@ -0,0 +1,3 @@ +page-core-mainmenu-more { + +} diff --git a/src/core/mainmenu/pages/more/more.ts b/src/core/mainmenu/pages/more/more.ts new file mode 100644 index 000000000..7f5b190cc --- /dev/null +++ b/src/core/mainmenu/pages/more/more.ts @@ -0,0 +1,129 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, OnDestroy } from '@angular/core'; +import { IonicPage, NavController } from 'ionic-angular'; +import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreMainMenuDelegate, CoreMainMenuHandlerData } from '../../providers/delegate'; +import { CoreMainMenuProvider, CoreMainMenuCustomItem } from '../../providers/mainmenu'; + +/** + * Page that displays the list of main menu options that aren't in the tabs. + */ +@IonicPage() +@Component({ + selector: 'page-core-mainmenu-more', + templateUrl: 'more.html', +}) +export class CoreMainMenuMorePage implements OnDestroy { + handlers: CoreMainMenuHandlerData[]; + handlersLoaded: boolean; + siteInfo: any; + logoutLabel: string; + showWeb: boolean; + showHelp: boolean; + docsUrl: string; + customItems: CoreMainMenuCustomItem[]; + + protected subscription; + protected langObserver; + protected updateSiteObserver; + + constructor(private menuDelegate: CoreMainMenuDelegate, private sitesProvider: CoreSitesProvider, + private navCtrl: NavController, private mainMenuProvider: CoreMainMenuProvider, eventsProvider: CoreEventsProvider) { + + this.langObserver = eventsProvider.on(CoreEventsProvider.LANGUAGE_CHANGED, this.loadSiteInfo.bind(this)); + this.updateSiteObserver = eventsProvider.on(CoreEventsProvider.SITE_UPDATED, (data) => { + if (sitesProvider.getCurrentSiteId() == data.siteId) { + this.loadSiteInfo(); + } + }); + + this.loadSiteInfo(); + } + + /** + * View loaded. + */ + ionViewDidLoad() { + // Load the handlers. + this.subscription = this.menuDelegate.getHandlers().subscribe((handlers) => { + this.handlers = handlers.slice(CoreMainMenuProvider.NUM_MAIN_HANDLERS); // Remove the main handlers. + this.handlersLoaded = this.menuDelegate.areHandlersLoaded(); + }); + } + + /** + * Page destroyed. + */ + ngOnDestroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + + /** + * Load the site info required by the view. + */ + protected loadSiteInfo() { + const currentSite = this.sitesProvider.getCurrentSite(), + config = currentSite.getStoredConfig(); + + this.siteInfo = currentSite.getInfo(); + this.logoutLabel = 'core.mainmenu.' + (config && config.tool_mobile_forcelogout == '1' ? 'logout': 'changesite'); + this.showWeb = !currentSite.isFeatureDisabled('$mmSideMenuDelegate_website'); + this.showHelp = !currentSite.isFeatureDisabled('$mmSideMenuDelegate_help'); + + currentSite.getDocsUrl().then((docsUrl) => { + this.docsUrl = docsUrl; + }); + + this.mainMenuProvider.getCustomMenuItems().then((items) => { + this.customItems = items; + }); + } + + /** + * Open a handler. + * + * @param {CoreMainMenuHandlerData} handler Handler to open. + */ + openHandler(handler: CoreMainMenuHandlerData) { + // @todo. + } + + /** + * Open an embedded custom item. + * + * @param {CoreMainMenuCustomItem} item Item to open. + */ + openItem(item: CoreMainMenuCustomItem) { + // @todo. + } + + /** + * Open settings page. + */ + openSettings() { + this.navCtrl.push('CoreSettingsListPage'); + } + + /** + * Logout the user. + */ + logout() { + this.sitesProvider.logout(); + } +} diff --git a/src/core/mainmenu/providers/mainmenu.ts b/src/core/mainmenu/providers/mainmenu.ts new file mode 100644 index 000000000..148e2473f --- /dev/null +++ b/src/core/mainmenu/providers/mainmenu.ts @@ -0,0 +1,135 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Injectable } from '@angular/core'; +import { CoreLangProvider } from '../../../providers/lang'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreConfigConstants } from '../../../configconstants'; + +export interface CoreMainMenuCustomItem { + type: string; + url: string; + label: string; + icon: string; +}; + +/** + * Service that provides some features regarding Main Menu. + */ +@Injectable() +export class CoreMainMenuProvider { + public static NUM_MAIN_HANDLERS = 4; + + constructor(private langProvider: CoreLangProvider, private sitesProvider: CoreSitesProvider) {} + + /** + * Get a list of custom menu items for a certain site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} List of custom menu items. + */ + getCustomMenuItems(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let itemsString = site.getStoredConfig('tool_mobile_custommenuitems'), + items, + position = 0, // Position of each item, to keep the same order as it's configured. + map = {}, + result = []; + + if (!itemsString || typeof itemsString != 'string') { + // Setting not valid. + return result; + } + + // Add items to the map. + items = itemsString.split(/(?:\r\n|\r|\n)/); + items.forEach((item) => { + let values = item.split('|'), + id, + label = values[0] ? values[0].trim() : values[0], + url = values[1] ? values[1].trim() : values[1], + type = values[2] ? values[2].trim() : values[2], + lang = (values[3] ? values[3].trim() : values[3]) || 'none', + icon = values[4] ? values[4].trim() : values[4]; + + if (!label || !url || !type) { + // Invalid item, ignore it. + return; + } + + id = url + '#' + type; + if (!icon) { + // Icon not defined, use default one. + icon = type == 'embedded' ? 'qr-scanner' : 'link'; + } + + if (!map[id]) { + // New entry, add it to the map. + map[id] = { + url: url, + type: type, + position: position, + labels: {} + }; + position++; + } + + map[id].labels[lang.toLowerCase()] = { + label: label, + icon: icon + }; + }); + + if (!position) { + // No valid items found, stop. + return result; + } + + return this.langProvider.getCurrentLanguage().then((currentLang) => { + const fallbackLang = CoreConfigConstants.default_lang || 'en'; + + // Get the right label for each entry and add it to the result. + for (let id in map) { + let entry = map[id], + data = entry.labels[currentLang] || entry.labels[currentLang + '_only'] || + entry.labels.none || entry.labels[fallbackLang]; + + if (!data) { + // No valid label found, get the first one that is not "_only". + for (let lang in entry.labels) { + if (lang.indexOf('_only') == -1) { + data = entry.labels[lang]; + break; + } + } + + if (!data) { + // No valid label, ignore this entry. + return; + } + } + + result[entry.position] = { + url: entry.url, + type: entry.type, + label: data.label, + icon: data.icon + }; + } + + return result; + }); + }); + } +} From f94cd92322e651dd487cb1e84f6074cc8633e528 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 11 Dec 2017 08:40:53 +0100 Subject: [PATCH 03/24] MOBILE-2302 core: Fix unsubscribe in CoreEventsProvider --- src/providers/events.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/providers/events.ts b/src/providers/events.ts index 60d8cf9f6..ee551eeb5 100644 --- a/src/providers/events.ts +++ b/src/providers/events.ts @@ -48,7 +48,7 @@ export class CoreEventsProvider { public static APP_LAUNCHED_URL = 'app_launched_url'; // App opened with a certain URL (custom URL scheme). logger; - observables = {}; + observables: {[s: string] : Subject} = {}; uniqueEvents = {}; constructor(logger: CoreLoggerProvider) { @@ -65,7 +65,7 @@ export class CoreEventsProvider { * @param {Function} callBack Function to call when the event is triggered. * @return {CoreEventObserver} Observer to stop listening. */ - on(eventName: string, callBack: Function) : CoreEventObserver { + on(eventName: string, callBack: (value: any) => void) : CoreEventObserver { // If it's a unique event and has been triggered already, call the callBack. // We don't need to create an observer because the event won't be triggered again. if (this.uniqueEvents[eventName]) { @@ -83,13 +83,13 @@ export class CoreEventsProvider { this.observables[eventName] = new Subject(); } - this.observables[eventName].subscribe(callBack); + let subscription = this.observables[eventName].subscribe(callBack); // Create and return a CoreEventObserver. return { off: () => { this.logger.debug(`Stop listening to event '${eventName}'`); - this.observables[eventName].unsubscribe(callBack); + subscription.unsubscribe(); } }; } From cda02ff68e1b93bb573d06a7ad82a9631a01e3bc Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 11 Dec 2017 08:56:55 +0100 Subject: [PATCH 04/24] MOBILE-2302 mainmenu: Allow viewing embedded custom items --- src/core/mainmenu/pages/more/more.ts | 2 +- src/core/viewer/pages/iframe/iframe.html | 8 +++++ src/core/viewer/pages/iframe/iframe.module.ts | 29 ++++++++++++++++ src/core/viewer/pages/iframe/iframe.ts | 34 +++++++++++++++++++ src/core/viewer/pages/text/text.ts | 2 +- 5 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 src/core/viewer/pages/iframe/iframe.html create mode 100644 src/core/viewer/pages/iframe/iframe.module.ts create mode 100644 src/core/viewer/pages/iframe/iframe.ts diff --git a/src/core/mainmenu/pages/more/more.ts b/src/core/mainmenu/pages/more/more.ts index 7f5b190cc..0f0b95ff8 100644 --- a/src/core/mainmenu/pages/more/more.ts +++ b/src/core/mainmenu/pages/more/more.ts @@ -110,7 +110,7 @@ export class CoreMainMenuMorePage implements OnDestroy { * @param {CoreMainMenuCustomItem} item Item to open. */ openItem(item: CoreMainMenuCustomItem) { - // @todo. + this.navCtrl.push('CoreViewerIframePage', {title: item.label, url: item.url}); } /** diff --git a/src/core/viewer/pages/iframe/iframe.html b/src/core/viewer/pages/iframe/iframe.html new file mode 100644 index 000000000..e070ebb9d --- /dev/null +++ b/src/core/viewer/pages/iframe/iframe.html @@ -0,0 +1,8 @@ + + + {{ title }} + + + + + diff --git a/src/core/viewer/pages/iframe/iframe.module.ts b/src/core/viewer/pages/iframe/iframe.module.ts new file mode 100644 index 000000000..f5cd9ddf4 --- /dev/null +++ b/src/core/viewer/pages/iframe/iframe.module.ts @@ -0,0 +1,29 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreViewerIframePage } from './iframe'; +import { CoreComponentsModule } from '../../../../components/components.module'; + +@NgModule({ + declarations: [ + CoreViewerIframePage + ], + imports: [ + CoreComponentsModule, + IonicPageModule.forChild(CoreViewerIframePage) + ] +}) +export class CoreViewerIframePageModule {} diff --git a/src/core/viewer/pages/iframe/iframe.ts b/src/core/viewer/pages/iframe/iframe.ts new file mode 100644 index 000000000..ba67d8d7e --- /dev/null +++ b/src/core/viewer/pages/iframe/iframe.ts @@ -0,0 +1,34 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, NavParams } from 'ionic-angular'; + +/** + * Page to display a URL in an iframe. + */ +@IonicPage() +@Component({ + selector: 'page-core-viewer-iframe', + templateUrl: 'iframe.html', +}) +export class CoreViewerIframePage { + title: string; // Page title. + url: string; // Iframe URL. + + constructor(params: NavParams) { + this.title = params.get('title'); + this.url = params.get('url'); + } +} \ No newline at end of file diff --git a/src/core/viewer/pages/text/text.ts b/src/core/viewer/pages/text/text.ts index 8a8d524d2..94486c69f 100644 --- a/src/core/viewer/pages/text/text.ts +++ b/src/core/viewer/pages/text/text.ts @@ -17,7 +17,7 @@ import { IonicPage, ViewController, NavParams } from 'ionic-angular'; import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; /** - * Component that displays an error when trying to connect to a site. + * Page to render a certain text. If opened as a modal, it will have a button to close the modal. */ @IonicPage() @Component({ From fe5af52d436b19ae7a685846adca18c36e6b92fd Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 11 Dec 2017 09:21:01 +0100 Subject: [PATCH 05/24] MOBILE-2302 core: Fix providers instantiated several times --- src/app/app.module.ts | 4 +++- src/core/login/pages/credentials/credentials.module.ts | 2 -- src/core/login/pages/email-signup/email-signup.module.ts | 2 -- .../pages/forgotten-password/forgotten-password.module.ts | 2 -- src/core/login/pages/init/init.module.ts | 2 -- src/core/login/pages/reconnect/reconnect.module.ts | 2 -- src/core/login/pages/site-policy/site-policy.module.ts | 2 -- src/core/login/pages/site/site.module.ts | 2 -- src/core/login/pages/sites/sites.module.ts | 2 -- src/core/mainmenu/pages/menu/menu.module.ts | 2 -- src/core/mainmenu/pages/more/more.module.ts | 2 -- 11 files changed, 3 insertions(+), 21 deletions(-) diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 5bf196048..bdea0e895 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -54,6 +54,7 @@ import { CoreUpdateManagerProvider } from '../providers/update-manager'; import { CorePluginFileDelegate } from '../providers/plugin-file-delegate'; import { CoreLoginModule } from '../core/login/login.module'; +import { CoreMainMenuModule } from '../core/mainmenu/mainmenu.module'; // For translate loader. AoT requires an exported function for factories. export function createTranslateLoader(http: HttpClient) { @@ -79,7 +80,8 @@ export function createTranslateLoader(http: HttpClient) { } }), CoreEmulatorModule, - CoreLoginModule + CoreLoginModule, + CoreMainMenuModule ], bootstrap: [IonicApp], entryComponents: [ diff --git a/src/core/login/pages/credentials/credentials.module.ts b/src/core/login/pages/credentials/credentials.module.ts index c69b2b8b4..88e6e955e 100644 --- a/src/core/login/pages/credentials/credentials.module.ts +++ b/src/core/login/pages/credentials/credentials.module.ts @@ -15,7 +15,6 @@ import { NgModule } from '@angular/core'; import { IonicPageModule } from 'ionic-angular'; import { CoreLoginCredentialsPage } from './credentials'; -import { CoreLoginModule } from '../../login.module'; import { TranslateModule } from '@ngx-translate/core'; import { CoreComponentsModule } from '../../../../components/components.module'; import { CoreDirectivesModule } from '../../../../directives/directives.module'; @@ -27,7 +26,6 @@ import { CoreDirectivesModule } from '../../../../directives/directives.module'; imports: [ CoreComponentsModule, CoreDirectivesModule, - CoreLoginModule, IonicPageModule.forChild(CoreLoginCredentialsPage), TranslateModule.forChild() ] diff --git a/src/core/login/pages/email-signup/email-signup.module.ts b/src/core/login/pages/email-signup/email-signup.module.ts index 37b1fd5b7..451715de6 100644 --- a/src/core/login/pages/email-signup/email-signup.module.ts +++ b/src/core/login/pages/email-signup/email-signup.module.ts @@ -15,7 +15,6 @@ import { NgModule } from '@angular/core'; import { IonicPageModule } from 'ionic-angular'; import { CoreLoginEmailSignupPage } from './email-signup'; -import { CoreLoginModule } from '../../login.module'; import { TranslateModule } from '@ngx-translate/core'; import { CoreComponentsModule } from '../../../../components/components.module'; import { CoreDirectivesModule } from '../../../../directives/directives.module'; @@ -27,7 +26,6 @@ import { CoreDirectivesModule } from '../../../../directives/directives.module'; imports: [ CoreComponentsModule, CoreDirectivesModule, - CoreLoginModule, IonicPageModule.forChild(CoreLoginEmailSignupPage), TranslateModule.forChild() ] diff --git a/src/core/login/pages/forgotten-password/forgotten-password.module.ts b/src/core/login/pages/forgotten-password/forgotten-password.module.ts index 9cbc9148d..0df3a92da 100644 --- a/src/core/login/pages/forgotten-password/forgotten-password.module.ts +++ b/src/core/login/pages/forgotten-password/forgotten-password.module.ts @@ -15,7 +15,6 @@ import { NgModule } from '@angular/core'; import { IonicPageModule } from 'ionic-angular'; import { CoreLoginForgottenPasswordPage } from './forgotten-password'; -import { CoreLoginModule } from '../../login.module'; import { TranslateModule } from '@ngx-translate/core'; @NgModule({ @@ -23,7 +22,6 @@ import { TranslateModule } from '@ngx-translate/core'; CoreLoginForgottenPasswordPage ], imports: [ - CoreLoginModule, IonicPageModule.forChild(CoreLoginForgottenPasswordPage), TranslateModule.forChild() ] diff --git a/src/core/login/pages/init/init.module.ts b/src/core/login/pages/init/init.module.ts index ee877d5ab..9a0744bc9 100644 --- a/src/core/login/pages/init/init.module.ts +++ b/src/core/login/pages/init/init.module.ts @@ -15,14 +15,12 @@ import { NgModule } from '@angular/core'; import { IonicPageModule } from 'ionic-angular'; import { CoreLoginInitPage } from './init'; -import { CoreLoginModule } from '../../login.module'; @NgModule({ declarations: [ CoreLoginInitPage, ], imports: [ - CoreLoginModule, IonicPageModule.forChild(CoreLoginInitPage), ], }) diff --git a/src/core/login/pages/reconnect/reconnect.module.ts b/src/core/login/pages/reconnect/reconnect.module.ts index 929973d46..10260486f 100644 --- a/src/core/login/pages/reconnect/reconnect.module.ts +++ b/src/core/login/pages/reconnect/reconnect.module.ts @@ -16,7 +16,6 @@ import { NgModule } from '@angular/core'; import { IonicPageModule } from 'ionic-angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreLoginReconnectPage } from './reconnect'; -import { CoreLoginModule } from '../../login.module'; import { CoreComponentsModule } from '../../../../components/components.module'; import { CoreDirectivesModule } from '../../../../directives/directives.module'; @@ -27,7 +26,6 @@ import { CoreDirectivesModule } from '../../../../directives/directives.module'; imports: [ CoreComponentsModule, CoreDirectivesModule, - CoreLoginModule, IonicPageModule.forChild(CoreLoginReconnectPage), TranslateModule.forChild() ] diff --git a/src/core/login/pages/site-policy/site-policy.module.ts b/src/core/login/pages/site-policy/site-policy.module.ts index 27168a1ff..c029e1c64 100644 --- a/src/core/login/pages/site-policy/site-policy.module.ts +++ b/src/core/login/pages/site-policy/site-policy.module.ts @@ -15,7 +15,6 @@ import { NgModule } from '@angular/core'; import { IonicPageModule } from 'ionic-angular'; import { CoreLoginSitePolicyPage } from './site-policy'; -import { CoreLoginModule } from '../../login.module'; import { TranslateModule } from '@ngx-translate/core'; import { CoreComponentsModule } from '../../../../components/components.module'; import { CoreDirectivesModule } from '../../../../directives/directives.module'; @@ -27,7 +26,6 @@ import { CoreDirectivesModule } from '../../../../directives/directives.module'; imports: [ CoreComponentsModule, CoreDirectivesModule, - CoreLoginModule, IonicPageModule.forChild(CoreLoginSitePolicyPage), TranslateModule.forChild() ] diff --git a/src/core/login/pages/site/site.module.ts b/src/core/login/pages/site/site.module.ts index a22ceb243..b446ba9fb 100644 --- a/src/core/login/pages/site/site.module.ts +++ b/src/core/login/pages/site/site.module.ts @@ -15,7 +15,6 @@ import { NgModule } from '@angular/core'; import { IonicPageModule } from 'ionic-angular'; import { CoreLoginSitePage } from './site'; -import { CoreLoginModule } from '../../login.module'; import { TranslateModule } from '@ngx-translate/core'; import { CoreDirectivesModule } from '../../../../directives/directives.module'; @@ -25,7 +24,6 @@ import { CoreDirectivesModule } from '../../../../directives/directives.module'; ], imports: [ CoreDirectivesModule, - CoreLoginModule, IonicPageModule.forChild(CoreLoginSitePage), TranslateModule.forChild() ] diff --git a/src/core/login/pages/sites/sites.module.ts b/src/core/login/pages/sites/sites.module.ts index 52f9e6601..c643dbe4e 100644 --- a/src/core/login/pages/sites/sites.module.ts +++ b/src/core/login/pages/sites/sites.module.ts @@ -16,7 +16,6 @@ import { NgModule } from '@angular/core'; import { IonicPageModule } from 'ionic-angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreLoginSitesPage } from './sites'; -import { CoreLoginModule } from '../../login.module'; import { CoreDirectivesModule } from '../../../../directives/directives.module'; @NgModule({ @@ -25,7 +24,6 @@ import { CoreDirectivesModule } from '../../../../directives/directives.module'; ], imports: [ CoreDirectivesModule, - CoreLoginModule, IonicPageModule.forChild(CoreLoginSitesPage), TranslateModule.forChild() ], diff --git a/src/core/mainmenu/pages/menu/menu.module.ts b/src/core/mainmenu/pages/menu/menu.module.ts index 2f8f96523..668c58c97 100644 --- a/src/core/mainmenu/pages/menu/menu.module.ts +++ b/src/core/mainmenu/pages/menu/menu.module.ts @@ -16,14 +16,12 @@ import { NgModule } from '@angular/core'; import { IonicPageModule } from 'ionic-angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreMainMenuPage } from './menu'; -import { CoreMainMenuModule } from '../../mainmenu.module'; @NgModule({ declarations: [ CoreMainMenuPage, ], imports: [ - CoreMainMenuModule, IonicPageModule.forChild(CoreMainMenuPage), TranslateModule.forChild() ], diff --git a/src/core/mainmenu/pages/more/more.module.ts b/src/core/mainmenu/pages/more/more.module.ts index dd338a3ea..f755c35c6 100644 --- a/src/core/mainmenu/pages/more/more.module.ts +++ b/src/core/mainmenu/pages/more/more.module.ts @@ -16,7 +16,6 @@ import { NgModule } from '@angular/core'; import { IonicPageModule } from 'ionic-angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreMainMenuMorePage } from './more'; -import { CoreMainMenuModule } from '../../mainmenu.module'; import { CoreComponentsModule } from '../../../../components/components.module'; import { CoreDirectivesModule } from '../../../../directives/directives.module'; @@ -27,7 +26,6 @@ import { CoreDirectivesModule } from '../../../../directives/directives.module'; imports: [ CoreComponentsModule, CoreDirectivesModule, - CoreMainMenuModule, IonicPageModule.forChild(CoreMainMenuMorePage), TranslateModule.forChild() ], From b09cdae3cd39adecbc9fd0be4f9e7149749fc851 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 11 Dec 2017 11:38:13 +0100 Subject: [PATCH 06/24] MOBILE-2302 courses: Implement CoreCoursesProvider --- src/app/app.module.ts | 4 +- src/core/courses/courses.module.ts | 27 + src/core/courses/providers/courses.ts | 811 ++++++++++++++++++++++++++ 3 files changed, 841 insertions(+), 1 deletion(-) create mode 100644 src/core/courses/courses.module.ts create mode 100644 src/core/courses/providers/courses.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index bdea0e895..7bd5875e5 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -55,6 +55,7 @@ import { CorePluginFileDelegate } from '../providers/plugin-file-delegate'; import { CoreLoginModule } from '../core/login/login.module'; import { CoreMainMenuModule } from '../core/mainmenu/mainmenu.module'; +import { CoreCoursesModule } from '../core/courses/courses.module'; // For translate loader. AoT requires an exported function for factories. export function createTranslateLoader(http: HttpClient) { @@ -81,7 +82,8 @@ export function createTranslateLoader(http: HttpClient) { }), CoreEmulatorModule, CoreLoginModule, - CoreMainMenuModule + CoreMainMenuModule, + CoreCoursesModule ], bootstrap: [IonicApp], entryComponents: [ diff --git a/src/core/courses/courses.module.ts b/src/core/courses/courses.module.ts new file mode 100644 index 000000000..96e18c1ec --- /dev/null +++ b/src/core/courses/courses.module.ts @@ -0,0 +1,27 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreCoursesProvider } from './providers/courses'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + CoreCoursesProvider + ] +}) +export class CoreCoursesModule {} diff --git a/src/core/courses/providers/courses.ts b/src/core/courses/providers/courses.ts new file mode 100644 index 000000000..fb2cd80ee --- /dev/null +++ b/src/core/courses/providers/courses.ts @@ -0,0 +1,811 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Injectable } from '@angular/core'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreSite } from '../../../classes/site'; + +/** + * Service that provides some features regarding lists of courses and categories. + */ +@Injectable() +export class CoreCoursesProvider { + public static SEARCH_PER_PAGE = 20; + public static ENROL_INVALID_KEY = 'CoreCoursesEnrolInvalidKey'; + protected logger; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider) { + this.logger = logger.getInstance('CoreCoursesProvider'); + } + + /** + * Get categories. They can be filtered by id. + * + * @param {number} categoryId Category ID to get. + * @param {boolean} [addSubcategories] If it should add subcategories to the list. + * @param {string} [siteId] Site to get the courses from. If not defined, use current site. + * @return {Promise} Promise resolved with the categories. + */ + getCategories(categoryId: number, addSubcategories?: boolean, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + // Get parent when id is the root category. + let criteriaKey = categoryId == 0 ? 'parent' : 'id', + data = { + criteria: [ + { key: criteriaKey, value: categoryId } + ], + addsubcategories: addSubcategories ? 1 : 0 + }, + preSets = { + cacheKey: this.getCategoriesCacheKey(categoryId, addSubcategories) + } + + return site.read('core_course_get_categories', data, preSets); + }); + } + + /** + * Get cache key for get categories methods WS call. + * + * @param {number} categoryId Category ID to get. + * @param {boolean} [addSubcategories] If add subcategories to the list. + * @return {string} Cache key. + */ + protected getCategoriesCacheKey(categoryId: number, addSubcategories?: boolean) : string { + return this.getRootCacheKey() + 'categories:' + categoryId + ':' + !!addSubcategories; + } + + /** + * Given a list of course IDs to get course options, return the list of courseIds to use. + * + * @param {number[]} courseIds Course IDs. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved with the list of course IDs. + */ + protected getCourseIdsForOptions(courseIds: number[], siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const siteHomeId = site.getSiteHomeId(); + + if (courseIds.length == 1) { + // Only 1 course, check if it belongs to the user courses. If so, use all user courses. + return this.getUserCourses(true, siteId).then((courses) => { + let courseId = courseIds[0], + useAllCourses = false; + + if (courseId == siteHomeId) { + // It's site home, use all courses. + useAllCourses = true; + } else { + for (let i = 0; i < courses.length; i++) { + if (courses[i].id == courseId) { + useAllCourses = true; + break; + } + } + } + + if (useAllCourses) { + // User is enrolled, retrieve all the courses. + courseIds = courses.map((course) => { + return course.id; + }); + + // Always add the site home ID. + courseIds.push(siteHomeId); + } + + return courseIds; + }).catch(() => { + // Ignore errors. + return courseIds; + }); + } else { + return courseIds; + } + }); + } + + /** + * Get the root cache key for the WS calls related to courses. + * + * @return {string} Root cache key. + */ + protected getRootCacheKey() : string { + return 'mmCourses:'; + } + + /** + * Check if My Courses is disabled in a certain site. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + isMyCoursesDisabled(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.isMyCoursesDisabledInSite(site); + }); + } + + /** + * Check if My Courses is disabled in a certain site. + * + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} Whether it's disabled. + */ + isMyCoursesDisabledInSite(site: CoreSite) : boolean { + site = site || this.sitesProvider.getCurrentSite(); + return site.isFeatureDisabled('$mmSideMenuDelegate_mmCourses'); + } + + /** + * Check if Search Courses is disabled in a certain site. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + isSearchCoursesDisabled(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.isSearchCoursesDisabledInSite(site); + }); + } + + /** + * Check if Search Courses is disabled in a certain site. + * + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} Whether it's disabled. + */ + isSearchCoursesDisabledInSite(site: CoreSite) : boolean { + site = site || this.sitesProvider.getCurrentSite(); + return site.isFeatureDisabled('$mmCoursesDelegate_search'); + } + + /** + * Get course. + * + * @param {number} id ID of the course to get. + * @param {string} [siteId] Site to get the courses from. If not defined, use current site. + * @return {Promise} Promise resolved with the course. + */ + getCourse(id: number, siteId?: string) : Promise { + return this.getCourses([id], siteId).then((courses) => { + if (courses && courses.length > 0) { + return courses[0]; + } + return Promise.reject(null); + }); + } + + /** + * Get the enrolment methods from a course. + * + * @param {number} id ID of the course. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let params = { + courseid: id + }, + preSets = { + cacheKey: this.getCourseEnrolmentMethodsCacheKey(id) + } + + return site.read('core_enrol_get_course_enrolment_methods', params, preSets); + }); + } + + /** + * Get cache key for get course enrolment methods WS call. + * + * @param {number} id Course ID. + * @return {string} Cache key. + */ + protected getCourseEnrolmentMethodsCacheKey(id: number) : string { + return this.getRootCacheKey() + 'enrolmentmethods:' + id; + } + + /** + * Get info from a course guest enrolment method. + * + * @param {number} instanceId Guest instance ID. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Promise resolved when the info is retrieved. + */ + getCourseGuestEnrolmentInfo(instanceId: number, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let params = { + instanceid: instanceId + }, + preSets = { + cacheKey: this.getCourseGuestEnrolmentInfoCacheKey(instanceId) + } + + return site.read('enrol_guest_get_instance_info', params, preSets).then((response) => { + return response.instanceinfo; + }); + }); + } + + /** + * Get cache key for get course guest enrolment methods WS call. + * + * @param {number} instanceId Guest instance ID. + * @return {string} Cache key. + */ + protected getCourseGuestEnrolmentInfoCacheKey(instanceId: number) : string { + return this.getRootCacheKey() + 'guestinfo:' + instanceId; + } + + /** + * Get courses. + * Warning: if the user doesn't have permissions to view some of the courses passed the WS call will fail. + * The user must be able to view ALL the courses passed. + * + * @param {number[]} ids List of IDs of the courses to get. + * @param {string} [siteId] Site to get the courses from. If not defined, use current site. + * @return {Promise} Promise resolved with the courses. + */ + getCourses(ids: number[], siteId?: string) : Promise { + if (!Array.isArray(ids)) { + return Promise.reject(null); + } else if (ids.length === 0) { + return Promise.resolve([]); + } + + return this.sitesProvider.getSite(siteId).then((site) => { + let data = { + options: { + ids: ids + } + }, + preSets = { + cacheKey: this.getCoursesCacheKey(ids) + } + + return site.read('core_course_get_courses', data, preSets); + }); + } + + /** + * Get cache key for get courses WS call. + * + * @param {number[]} ids Courses IDs. + * @return {string} Cache key. + */ + protected getCoursesCacheKey(ids: number[]) : string { + return this.getRootCacheKey() + 'course:' + JSON.stringify(ids); + } + + /** + * Get courses. They can be filtered by field. + * + * @param {string} [field] The field to search. Can be left empty for all courses or: + * id: course id. + * ids: comma separated course ids. + * shortname: course short name. + * idnumber: course id number. + * category: category id the course belongs to. + * @param {any} [value] The value to match. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Promise resolved with the courses. + */ + getCoursesByField(field?: string, value?: any, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let data = { + field: field || '', + value: field ? value : '' + }, + preSets = { + cacheKey: this.getCoursesByFieldCacheKey(field, value) + } + + return site.read('core_course_get_courses_by_field', data, preSets).then((courses) => { + if (courses.courses) { + // Courses will be sorted using sortorder if avalaible. + return courses.courses.sort((a, b) => { + if (typeof a.sortorder == 'undefined' && typeof b.sortorder == 'undefined') { + return b.id - a.id; + } + + if (typeof a.sortorder == 'undefined') { + return 1; + } + + if (typeof b.sortorder == 'undefined') { + return -1; + } + + return a.sortorder - b.sortorder; + }); + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for get courses WS call. + * + * @param {string} [field] The field to search. + * @param {any} [value] The value to match. + * @return {string} Cache key. + */ + protected getCoursesByFieldCacheKey(field?: string, value?: any) : string { + field = field || ''; + value = field ? value : ''; + return this.getRootCacheKey() + 'coursesbyfield:' + field + ':' + value; + } + + /** + * Check if get courses by field WS is available. + * + * @return {boolean} Whether get courses by field is available. + */ + isGetCoursesByFieldAvailable() : boolean { + let currentSite = this.sitesProvider.getCurrentSite(); + return currentSite.wsAvailable('core_course_get_courses_by_field'); + } + + /** + * Get the navigation and administration options for the given courses. + * + * @param {number[]} courseIds IDs of courses to get. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{navOptions: any, admOptions: any}>} Promise resolved with the options for each course. + */ + getCoursesOptions(courseIds: number[], siteId?: string) : Promise<{navOptions: any, admOptions: any}> { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Get the list of courseIds to use based on the param. + return this.getCourseIdsForOptions(courseIds, siteId).then((courseIds) => { + let promises = [], + navOptions, + admOptions; + + // Get user navigation and administration options. + promises.push(this.getUserNavigationOptions(courseIds, siteId).catch(() => { + // Couldn't get it, return empty options. + return {}; + }).then((options) => { + navOptions = options; + })); + + promises.push(this.getUserAdministrationOptions(courseIds, siteId).catch(() => { + // Couldn't get it, return empty options. + return {}; + }).then((options) => { + admOptions = options; + })); + + return Promise.all(promises).then(() => { + return {navOptions: navOptions, admOptions: admOptions}; + }); + }); + } + + /** + * Get the common part of the cache keys for user administration options WS calls. + * + * @return {string} Cache key. + */ + protected getUserAdministrationOptionsCommonCacheKey() : string { + return this.getRootCacheKey() + 'administrationOptions:'; + } + + /** + * Get cache key for get user administration options WS call. + * + * @param {number[]} courseIds IDs of courses to get. + * @return {string} Cache key. + */ + protected getUserAdministrationOptionsCacheKey(courseIds: number[]) : string { + return this.getUserAdministrationOptionsCommonCacheKey() + courseIds.join(','); + } + + /** + * Get user administration options for a set of courses. + * + * @param {number[]} courseIds IDs of courses to get. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with administration options for each course. + */ + getUserAdministrationOptions(courseIds: number[], siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let params = { + courseids: courseIds + }, + preSets = { + cacheKey: this.getUserAdministrationOptionsCacheKey(courseIds) + } + + return site.read('core_course_get_user_administration_options', params, preSets).then((response) => { + // Format returned data. + return this.formatUserOptions(response.courses); + }); + }); + } + + /** + * Get the common part of the cache keys for user navigation options WS calls. + * + * @param {number[]} courseIds IDs of courses to get. + * @return {string} Cache key. + */ + protected getUserNavigationOptionsCommonCacheKey() : string { + return this.getRootCacheKey() + 'navigationOptions:'; + } + + /** + * Get cache key for get user navigation options WS call. + * + * @return {string} Cache key. + */ + protected getUserNavigationOptionsCacheKey(courseIds: number[]) : string { + return this.getUserNavigationOptionsCommonCacheKey() + courseIds.join(','); + } + + /** + * Get user navigation options for a set of courses. + * + * @param {number[]} courseIds IDs of courses to get. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with navigation options for each course. + */ + getUserNavigationOptions(courseIds: number[], siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let params = { + courseids: courseIds + }, + preSets = { + cacheKey: this.getUserNavigationOptionsCacheKey(courseIds) + } + + return site.read('core_course_get_user_navigation_options', params, preSets).then((response) => { + // Format returned data. + return this.formatUserOptions(response.courses); + }); + }); + } + + /** + * Format user navigation or administration options. + * + * @param {any[]} courses Navigation or administration options for each course. + * @return {any} Formatted options. + */ + protected formatUserOptions(courses: any[]) : any { + let result = {}; + + courses.forEach((course) => { + let options = {}; + + if (course.options) { + course.options.forEach((option) => { + options[option.name] = option.available; + }); + } + + result[course.id] = options; + }); + + return result; + } + + /** + * Get a course the user is enrolled in. This function relies on getUserCourses. + * preferCache=true will try to speed up the response, but the data returned might not be updated. + * + * @param {number} id ID of the course to get. + * @param {boolean} [preferCache] True if shouldn't call WS if data is cached, false otherwise. + * @param {string} [siteId] Site to get the courses from. If not defined, use current site. + * @return {Promise} Promise resolved with the course. + */ + getUserCourse(id: number, preferCache?: boolean, siteId?: string) : Promise { + if (!id) { + return Promise.reject(null); + } + + return this.getUserCourses(preferCache, siteId).then((courses) => { + let course; + for (let i in courses) { + if (courses[i].id == id) { + course = courses[i]; + break; + } + } + + return course ? course : Promise.reject(null); + }); + } + + /** + * Get user courses. + * + * @param {boolean} [preferCache] True if shouldn't call WS if data is cached, false otherwise. + * @param {string} [siteId] Site to get the courses from. If not defined, use current site. + * @return {Promise} Promise resolved with the courses. + */ + getUserCourses(preferCache?: boolean, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + + let userId = site.getUserId(), + data = { + userid: userId + }, + preSets = { + cacheKey: this.getUserCoursesCacheKey(), + omitExpires: !!preferCache + }; + + return site.read('core_enrol_get_users_courses', data, preSets); + }); + } + + /** + * Get cache key for get user courses WS call. + * + * @return {string} Cache key. + */ + protected getUserCoursesCacheKey() : string { + return this.getRootCacheKey() + 'usercourses'; + } + + /** + * Invalidates get categories WS call. + * + * @param {number} categoryId Category ID to get. + * @param {boolean} [addSubcategories] If it should add subcategories to the list. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCategories(categoryId: number, addSubcategories?: boolean, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCategoriesCacheKey(categoryId, addSubcategories)); + }); + } + + /** + * Invalidates get course WS call. + * + * @param {number} id Course ID. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCourse(id: number, siteId?: string) : Promise { + return this.invalidateCourses([id], siteId); + } + + /** + * Invalidates get course enrolment methods WS call. + * + * @param {number} id Course ID. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCourseEnrolmentMethods(id: number, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCourseEnrolmentMethodsCacheKey(id)); + }); + } + + /** + * Invalidates get course guest enrolment info WS call. + * + * @param {number} instanceId Guest instance ID. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCourseGuestEnrolmentInfo(instanceId: number, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCourseGuestEnrolmentInfoCacheKey(instanceId)); + }); + } + + /** + * Invalidates the navigation and administration options for the given courses. + * + * @param {number[]} courseIds IDs of courses to get. + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCoursesOptions(courseIds: number[], siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.getCourseIdsForOptions(courseIds, siteId).then((ids) => { + let promises = []; + + promises.push(this.invalidateUserAdministrationOptionsForCourses(ids, siteId)); + promises.push(this.invalidateUserNavigationOptionsForCourses(ids, siteId)); + + return Promise.all(promises); + }); + } + + /** + * Invalidates get courses WS call. + * + * @param {number[]} ids Courses IDs. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCourses(ids: number[], siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCoursesCacheKey(ids)); + }); + } + + /** + * Invalidates get courses by field WS call. + * + * @param {string} [field] See getCoursesByField for info. + * @param {any} [value] The value to match. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCoursesByField(field?: string, value?: any, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCoursesByFieldCacheKey(field, value)); + }); + } + + /** + * Invalidates all user administration options. + * + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserAdministrationOptions(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getUserAdministrationOptionsCommonCacheKey()); + }); + } + + /** + * Invalidates user administration options for certain courses. + * + * @param {number[]} courseIds IDs of courses. + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserAdministrationOptionsForCourses(courseIds: number[], siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getUserAdministrationOptionsCacheKey(courseIds)); + }); + } + + /** + * Invalidates get user courses WS call. + * + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserCourses(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getUserCoursesCacheKey()); + }); + } + + /** + * Invalidates all user navigation options. + * + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserNavigationOptions(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getUserNavigationOptionsCommonCacheKey()); + }); + } + + /** + * Invalidates user navigation options for certain courses. + * + * @param {number[]} courseIds IDs of courses. + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserNavigationOptionsForCourses(courseIds: number[], siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getUserNavigationOptionsCacheKey(courseIds)); + }); + } + + /** + * Check if WS to retrieve guest enrolment data is available. + * + * @return {boolean} Whether guest WS is available. + */ + isGuestWSAvailable() : boolean { + let currentSite = this.sitesProvider.getCurrentSite(); + return currentSite && currentSite.wsAvailable('enrol_guest_get_instance_info'); + } + + /** + * Search courses. + * + * @param {string} text Text to search. + * @param {number} [page=0] Page to get. + * @param {number} [perPage] Number of courses per page. Defaults to CoreCoursesProvider.SEARCH_PER_PAGE. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise<{total: number, courses: any[]}>} Promise resolved with the courses and the total of matches. + */ + search(text: string, page = 0, perPage?: number, siteId?: string) : Promise<{total: number, courses: any[]}> { + perPage = perPage || CoreCoursesProvider.SEARCH_PER_PAGE; + + return this.sitesProvider.getSite(siteId).then((site) => { + let params = { + criterianame: 'search', + criteriavalue: text, + page: page, + perpage: perPage + }, preSets = { + getFromCache: false + } + + return site.read('core_course_search_courses', params, preSets).then((response) => { + return {total: response.total, courses: response.courses}; + }); + }); + } + + /** + * Self enrol current user in a certain course. + * + * @param {number} courseId Course ID. + * @param {string} [password] Password to use. + * @param {number} [instanceId] Enrol instance ID. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Promise resolved if the user is enrolled. If the password is invalid, the promise is rejected + * with an object with code = CoreCoursesProvider.ENROL_INVALID_KEY. + */ + selfEnrol(courseId: number, password = '', instanceId?: number, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + + let params: any = { + courseid: courseId, + password: password + } + if (instanceId) { + params.instanceid = instanceId; + } + + return site.write('enrol_self_enrol_user', params).then((response) : any => { + if (response) { + if (response.status) { + return true; + } else if (response.warnings && response.warnings.length) { + let message; + response.warnings.forEach((warning) => { + // Invalid password warnings. + if (warning.warningcode == '2' || warning.warningcode == '3' || warning.warningcode == '4') { + message = warning.message; + } + }); + + if (message) { + return Promise.reject({code: CoreCoursesProvider.ENROL_INVALID_KEY, message: message}); + } else { + return Promise.reject(response.warnings[0]); + } + } + } + return Promise.reject(null); + }); + }); + } +} From 296932b76a48e9e9ed3f7e7c9bbe0acb0b7a259e Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 12 Dec 2017 15:41:05 +0100 Subject: [PATCH 07/24] MOBILE-2302 courses: Add lang files --- src/core/courses/lang/ar.json | 19 +++++++++++++++++++ src/core/courses/lang/bg.json | 16 ++++++++++++++++ src/core/courses/lang/ca.json | 30 ++++++++++++++++++++++++++++++ src/core/courses/lang/cs.json | 30 ++++++++++++++++++++++++++++++ src/core/courses/lang/da.json | 28 ++++++++++++++++++++++++++++ src/core/courses/lang/de-du.json | 30 ++++++++++++++++++++++++++++++ src/core/courses/lang/de.json | 30 ++++++++++++++++++++++++++++++ src/core/courses/lang/el.json | 30 ++++++++++++++++++++++++++++++ src/core/courses/lang/en.json | 31 +++++++++++++++++++++++++++++++ src/core/courses/lang/es-mx.json | 30 ++++++++++++++++++++++++++++++ src/core/courses/lang/es.json | 30 ++++++++++++++++++++++++++++++ src/core/courses/lang/eu.json | 30 ++++++++++++++++++++++++++++++ src/core/courses/lang/fa.json | 20 ++++++++++++++++++++ src/core/courses/lang/fi.json | 30 ++++++++++++++++++++++++++++++ src/core/courses/lang/fr.json | 30 ++++++++++++++++++++++++++++++ src/core/courses/lang/he.json | 20 ++++++++++++++++++++ src/core/courses/lang/hr.json | 22 ++++++++++++++++++++++ src/core/courses/lang/hu.json | 19 +++++++++++++++++++ src/core/courses/lang/it.json | 28 ++++++++++++++++++++++++++++ src/core/courses/lang/ja.json | 19 +++++++++++++++++++ src/core/courses/lang/lt.json | 28 ++++++++++++++++++++++++++++ src/core/courses/lang/mr.json | 29 +++++++++++++++++++++++++++++ src/core/courses/lang/nl.json | 30 ++++++++++++++++++++++++++++++ src/core/courses/lang/no.json | 18 ++++++++++++++++++ src/core/courses/lang/pl.json | 19 +++++++++++++++++++ src/core/courses/lang/pt-br.json | 30 ++++++++++++++++++++++++++++++ src/core/courses/lang/pt.json | 30 ++++++++++++++++++++++++++++++ src/core/courses/lang/ro.json | 28 ++++++++++++++++++++++++++++ src/core/courses/lang/ru.json | 21 +++++++++++++++++++++ src/core/courses/lang/sr-cr.json | 27 +++++++++++++++++++++++++++ src/core/courses/lang/sr-lt.json | 27 +++++++++++++++++++++++++++ src/core/courses/lang/sv.json | 28 ++++++++++++++++++++++++++++ src/core/courses/lang/tr.json | 19 +++++++++++++++++++ src/core/courses/lang/uk.json | 30 ++++++++++++++++++++++++++++++ src/core/courses/lang/zh-cn.json | 16 ++++++++++++++++ src/core/courses/lang/zh-tw.json | 25 +++++++++++++++++++++++++ 36 files changed, 927 insertions(+) create mode 100644 src/core/courses/lang/ar.json create mode 100644 src/core/courses/lang/bg.json create mode 100644 src/core/courses/lang/ca.json create mode 100644 src/core/courses/lang/cs.json create mode 100644 src/core/courses/lang/da.json create mode 100644 src/core/courses/lang/de-du.json create mode 100644 src/core/courses/lang/de.json create mode 100644 src/core/courses/lang/el.json create mode 100644 src/core/courses/lang/en.json create mode 100644 src/core/courses/lang/es-mx.json create mode 100644 src/core/courses/lang/es.json create mode 100644 src/core/courses/lang/eu.json create mode 100644 src/core/courses/lang/fa.json create mode 100644 src/core/courses/lang/fi.json create mode 100644 src/core/courses/lang/fr.json create mode 100644 src/core/courses/lang/he.json create mode 100644 src/core/courses/lang/hr.json create mode 100644 src/core/courses/lang/hu.json create mode 100644 src/core/courses/lang/it.json create mode 100644 src/core/courses/lang/ja.json create mode 100644 src/core/courses/lang/lt.json create mode 100644 src/core/courses/lang/mr.json create mode 100644 src/core/courses/lang/nl.json create mode 100644 src/core/courses/lang/no.json create mode 100644 src/core/courses/lang/pl.json create mode 100644 src/core/courses/lang/pt-br.json create mode 100644 src/core/courses/lang/pt.json create mode 100644 src/core/courses/lang/ro.json create mode 100644 src/core/courses/lang/ru.json create mode 100644 src/core/courses/lang/sr-cr.json create mode 100644 src/core/courses/lang/sr-lt.json create mode 100644 src/core/courses/lang/sv.json create mode 100644 src/core/courses/lang/tr.json create mode 100644 src/core/courses/lang/uk.json create mode 100644 src/core/courses/lang/zh-cn.json create mode 100644 src/core/courses/lang/zh-tw.json diff --git a/src/core/courses/lang/ar.json b/src/core/courses/lang/ar.json new file mode 100644 index 000000000..82cbdc293 --- /dev/null +++ b/src/core/courses/lang/ar.json @@ -0,0 +1,19 @@ +{ + "allowguests": "يسمح للمستخدمين الضيوف بالدخول إلى هذا المقرر الدراسي", + "availablecourses": "المقررات الدراسية المتاحة", + "categories": "تصنيفات المقررات الدراسية", + "courses": "المقررات الدراسية", + "enrolme": "سجلني", + "frontpage": "الصفحة الرئيسية", + "mycourses": "مقرراتي الدراسية", + "nocourses": "لا يوجد معلومات لمقرر دراسي ليتم اظهرها", + "nocoursesyet": "لا توجد مقررات دراسية لهذه الفئة", + "nosearchresults": "لا توجد نتائج لهذا البحث", + "notenroled": "أنت لست مسجلاً كطالب في هذا المقرر", + "password": "كلمة المرور", + "paymentrequired": "هذا المقرر الدراسي غير مجانين لذا يجب دفع القيمة للدخول.", + "paypalaccepted": "تم قبول التبرع المدفوع", + "search": "بحث", + "searchcourses": "بحث مقررات دراسية", + "sendpaymentbutton": "ارسل القيمة المدفوعة عن طريق التبرع" +} \ No newline at end of file diff --git a/src/core/courses/lang/bg.json b/src/core/courses/lang/bg.json new file mode 100644 index 000000000..704b295e7 --- /dev/null +++ b/src/core/courses/lang/bg.json @@ -0,0 +1,16 @@ +{ + "allowguests": "В този курс могат да влизат гости", + "availablecourses": "Налични курсове", + "categories": "Категории курсове", + "courses": "Курсове", + "enrolme": "Запишете ме", + "errorloadcourses": "Грешка при зареждането на курсовете.", + "frontpage": "Заглавна страница", + "mycourses": "Моите курсове", + "nocourses": "Няма информация за курса, която да бъде показана.", + "nocoursesyet": "Няма курсове в тази категория", + "nosearchresults": "Няма открити резултати за Вашето търсене", + "password": "Ключ за записване", + "search": "Търсене", + "searchcourses": "Търсене на курсове" +} \ No newline at end of file diff --git a/src/core/courses/lang/ca.json b/src/core/courses/lang/ca.json new file mode 100644 index 000000000..6cc07396f --- /dev/null +++ b/src/core/courses/lang/ca.json @@ -0,0 +1,30 @@ +{ + "allowguests": "Aquest curs permet entrar als usuaris visitants", + "availablecourses": "Cursos disponibles", + "cannotretrievemorecategories": "No es poden recuperar categories més enllà del nivell {{$a}}.", + "categories": "Categories de cursos", + "confirmselfenrol": "Segur que voleu autoinscriure-us en aquest curs?", + "courses": "Cursos", + "enrolme": "Inscriu-me", + "errorloadcategories": "S'ha produït un error en carregar les categories.", + "errorloadcourses": "S'ha produït un error carregant els cursos.", + "errorsearching": "S'ha produït un error durant la cerca.", + "errorselfenrol": "S'ha produït un error durant l'autoinscripció.", + "filtermycourses": "Filtrar els meus cursos", + "frontpage": "Pàgina principal", + "mycourses": "Els meus cursos", + "nocourses": "No hi ha informació de cursos per mostrar.", + "nocoursesyet": "No hi ha cursos en aquesta categoria", + "nosearchresults": "La cerca no ha obtingut resultats", + "notenroled": "No us heu inscrit en aquest curs", + "notenrollable": "No podeu autoinscriure-us en aquest curs.", + "password": "Contrasenya", + "paymentrequired": "Aquest curs requereix pagament.", + "paypalaccepted": "S'accepten pagaments via PayPal", + "search": "Cerca...", + "searchcourses": "Cerca cursos", + "searchcoursesadvice": "Podeu fer servir el botó de cercar cursos per accedir als cursos com a convidat o autoinscriure-us en cursos que ho permetin.", + "selfenrolment": "Autoinscripció", + "sendpaymentbutton": "Envia pagament via Paypal", + "totalcoursesearchresults": "Total de cursos: {{$a}}" +} \ No newline at end of file diff --git a/src/core/courses/lang/cs.json b/src/core/courses/lang/cs.json new file mode 100644 index 000000000..4f1701f97 --- /dev/null +++ b/src/core/courses/lang/cs.json @@ -0,0 +1,30 @@ +{ + "allowguests": "Tento kurz je otevřen i pro hosty", + "availablecourses": "Dostupné kurzy", + "cannotretrievemorecategories": "Kategorie hlubší než úroveň {{$a}} nelze načíst.", + "categories": "Kategorie kurzů", + "confirmselfenrol": "Jste si jisti, že chcete zapsat se do tohoto kurzu?", + "courses": "Kurzy", + "enrolme": "Zapsat se do kurzu", + "errorloadcategories": "Při načítání kategorií došlo k chybě.", + "errorloadcourses": "Při načítání kurzů došlo k chybě.", + "errorsearching": "Při vyhledávání došlo k chybě.", + "errorselfenrol": "Při zápisu sebe sama došlo k chybě.", + "filtermycourses": "Filtrovat mé kurzy", + "frontpage": "Titulní stránka", + "mycourses": "Moje kurzy", + "nocourses": "Žádné dostupné informace o kurzech", + "nocoursesyet": "Žádný kurz v této kategorii", + "nosearchresults": "Vaše vyhledávání nepřineslo žádný výsledek", + "notenroled": "Nejste zapsáni v tomto kurzu", + "notenrollable": "Do tohoto kurzu se nemůžete sami zapsat.", + "password": "Heslo", + "paymentrequired": "Tento kurz je placený", + "paypalaccepted": "Platby přes PayPal přijímány", + "search": "Hledat", + "searchcourses": "Vyhledat kurzy", + "searchcoursesadvice": "Můžete použít tlačítko Vyhledat kurzy, pracovat jako host nebo se zapsat do kurzů, které to umožňují.", + "selfenrolment": "Zápis sebe sama", + "sendpaymentbutton": "Poslat platbu přes službu PayPal", + "totalcoursesearchresults": "Celkem kurzů: {{$a}}" +} \ No newline at end of file diff --git a/src/core/courses/lang/da.json b/src/core/courses/lang/da.json new file mode 100644 index 000000000..7f0aaebd8 --- /dev/null +++ b/src/core/courses/lang/da.json @@ -0,0 +1,28 @@ +{ + "allowguests": "Dette kursus tillader gæster", + "availablecourses": "Tilgængelige kurser", + "categories": "Kursuskategorier", + "confirmselfenrol": "Er du sikker på at du ønsker at tilmelde dig dette kursus?", + "courses": "Alle kurser", + "enrolme": "Tilmeld mig", + "errorloadcourses": "En fejl opstod ved indlæsning af kurset.", + "errorsearching": "En fejl opstod under søgning.", + "errorselfenrol": "En fejl opstod under selvtilmelding.", + "filtermycourses": "Filtrer mit kursus", + "frontpage": "Forside", + "mycourses": "Mine kurser", + "nocourses": "Du er ikke tilmeldt nogen kurser.", + "nocoursesyet": "Der er ingen kurser i denne kategori", + "nosearchresults": "Der var ingen beskeder der opfyldte søgekriteriet", + "notenroled": "Du er ikke tilmeldt dette kursus", + "notenrollable": "Du kan ikke selv tilmelde dig dette kursus.", + "password": "Adgangskode", + "paymentrequired": "Dette kursus kræver betaling for tilmelding.", + "paypalaccepted": "PayPal-betalinger er velkomne", + "search": "Søg...", + "searchcourses": "Søg efter kurser", + "searchcoursesadvice": "Du kan bruge knappen kursussøgning for at få adgang som gæst eller tilmelde dig kurser der tillader det.", + "selfenrolment": "Selvtilmelding", + "sendpaymentbutton": "Send betaling via PayPal", + "totalcoursesearchresults": "Kurser i alt: {{$a}}" +} \ No newline at end of file diff --git a/src/core/courses/lang/de-du.json b/src/core/courses/lang/de-du.json new file mode 100644 index 000000000..13bd07bd7 --- /dev/null +++ b/src/core/courses/lang/de-du.json @@ -0,0 +1,30 @@ +{ + "allowguests": "Dieser Kurs erlaubt einen Gastzugang.", + "availablecourses": "Kursliste", + "cannotretrievemorecategories": "Kursbereiche tiefer als Level {{$a}} können nicht abgerufen werden.", + "categories": "Kursbereiche", + "confirmselfenrol": "Möchtest du dich selbst in diesen Kurs einschreiben?", + "courses": "Kurse", + "enrolme": "Einschreiben", + "errorloadcategories": "Fehler beim Laden von Kursbereichen", + "errorloadcourses": "Fehler beim Laden von Kursen", + "errorsearching": "Fehler beim Suchen", + "errorselfenrol": "Fehler bei der Selbsteinschreibung", + "filtermycourses": "Meine Kurse filtern", + "frontpage": "Startseite", + "mycourses": "Meine Kurse", + "nocourses": "Keine Kurse", + "nocoursesyet": "Keine Kurse in diesem Kursbereich", + "nosearchresults": "Keine Ergebnisse", + "notenroled": "Sie sind nicht in diesen Kurs eingeschrieben", + "notenrollable": "Du kannst dich nicht selbst in diesen Kurs einschreiben.", + "password": "Öffentliches Kennwort", + "paymentrequired": "Dieser Kurs ist gebührenpflichtig. Bitte bezahle die Teilnahmegebühr, um im Kurs eingeschrieben zu werden.", + "paypalaccepted": "PayPal-Zahlungen möglich", + "search": "Suchen", + "searchcourses": "Kurse suchen", + "searchcoursesadvice": "Du kannst Kurse suchen, um als Gast teilzunehmen oder dich selbst einzuschreiben, falls dies erlaubt ist.", + "selfenrolment": "Selbsteinschreibung", + "sendpaymentbutton": "Zahlung über PayPal", + "totalcoursesearchresults": "Alle Kurse: {{$a}}" +} \ No newline at end of file diff --git a/src/core/courses/lang/de.json b/src/core/courses/lang/de.json new file mode 100644 index 000000000..3f93d4db3 --- /dev/null +++ b/src/core/courses/lang/de.json @@ -0,0 +1,30 @@ +{ + "allowguests": "Dieser Kurs erlaubt einen Gastzugang.", + "availablecourses": "Kursliste", + "cannotretrievemorecategories": "Kursbereiche tiefer als Level {{$a}} können nicht abgerufen werden.", + "categories": "Kursbereiche", + "confirmselfenrol": "Möchten Sie sich selbst in diesen Kurs einschreiben?", + "courses": "Kurse", + "enrolme": "Einschreiben", + "errorloadcategories": "Fehler beim Laden von Kursbereichen", + "errorloadcourses": "Fehler beim Laden von Kursen", + "errorsearching": "Fehler beim Suchen", + "errorselfenrol": "Fehler bei der Selbsteinschreibung", + "filtermycourses": "Meine Kurse filtern", + "frontpage": "Startseite", + "mycourses": "Meine Kurse", + "nocourses": "Keine Kurse", + "nocoursesyet": "Keine Kurse in diesem Kursbereich", + "nosearchresults": "Keine Suchergebnisse", + "notenroled": "Sie sind nicht in diesen Kurs eingeschrieben", + "notenrollable": "Sie können sich nicht selbst in diesen Kurs einschreiben.", + "password": "Öffentliches Kennwort", + "paymentrequired": "Dieser Kurs ist entgeltpflichtig. Bitte bezahlen Sie das Teilnahmeentgelt, um in den Kurs eingeschrieben zu werden.", + "paypalaccepted": "PayPal-Zahlungen möglich", + "search": "Suchen", + "searchcourses": "Kurse suchen", + "searchcoursesadvice": "Sie können Kurse suchen, um als Gast teilzunehmen oder sich selbst einzuschreiben, falls dies erlaubt ist.", + "selfenrolment": "Selbsteinschreibung", + "sendpaymentbutton": "Zahlung über PayPal", + "totalcoursesearchresults": "Alle Kurse: {{$a}}" +} \ No newline at end of file diff --git a/src/core/courses/lang/el.json b/src/core/courses/lang/el.json new file mode 100644 index 000000000..9b9181e25 --- /dev/null +++ b/src/core/courses/lang/el.json @@ -0,0 +1,30 @@ +{ + "allowguests": "Σε αυτό το μάθημα επιτρέπονται και οι επισκέπτες", + "availablecourses": "Διαθέσιμα Μαθήματα", + "cannotretrievemorecategories": "Δεν είναι δυνατή η ανάκτηση κατηγοριών μετά από το επίπεδο {{$a}}.", + "categories": "Κατηγορίες μαθημάτων", + "confirmselfenrol": "Είστε σίγουροι ότι θέλετε να εγγραφείτε σε αυτό το μάθημα;", + "courses": "Μαθήματα", + "enrolme": "Εγγραφή", + "errorloadcategories": "Παρουσιάστηκε σφάλμα κατά την φόρτωση των κατηγοριών.", + "errorloadcourses": "Παρουσιάστηκε σφάλμα κατά τη φόρτωση των μαθημάτων.", + "errorsearching": "Παρουσιάστηκε σφάλμα κατά τη διάρκεια της αναζήτησης.", + "errorselfenrol": "Παρουσιάστηκε σφάλμα κατά τη διάρκεια της αυτο-εγγραφής.", + "filtermycourses": "Φιλτράρισμα των μαθημάτων μου", + "frontpage": "Αρχική σελίδα", + "mycourses": "Τα μαθήματά μου", + "nocourses": "Δεν υπάρχει πληροφορία του μαθήματος για προβολή.", + "nocoursesyet": "Δεν υπάρχουν μαθήματα σε αυτήν την κατηγορία", + "nosearchresults": "Δε βρέθηκαν αποτελέσματα για την αναζήτησή σας", + "notenroled": "Δεν είσαι εγγεγραμμένος σε αυτό το μάθημα", + "notenrollable": "Δεν μπορείτε να αυτο-εγγραφείτε σε αυτό το μάθημα.", + "password": "Κωδικός πρόσβασης", + "paymentrequired": "Αυτό το μάθημα απαιτεί πληρωμή για την είσοδο.", + "paypalaccepted": "Αποδεκτές οι πληρωμές μέσω PayPal", + "search": "Αναζήτηση", + "searchcourses": "Αναζήτηση μαθημάτων", + "searchcoursesadvice": "Μπορείτε να χρησιμοποιήσετε το κουμπί Αναζήτηση μαθημάτων για πρόσβαση ως επισκέπτης ή για να αυτο-εγγραφείτε σε μαθήματα που το επιτρέπουν.", + "selfenrolment": "Αυτο-εγγραφή", + "sendpaymentbutton": "Αποστολή πληρωμής με Paypal", + "totalcoursesearchresults": "Συνολικά μαθήματα: {{$a}}" +} \ No newline at end of file diff --git a/src/core/courses/lang/en.json b/src/core/courses/lang/en.json new file mode 100644 index 000000000..f04f3836d --- /dev/null +++ b/src/core/courses/lang/en.json @@ -0,0 +1,31 @@ +{ + "allowguests": "This course allows guest users to enter", + "availablecourses": "Available courses", + "cannotretrievemorecategories": "Categories deeper than level {{$a}} cannot be retrieved.", + "categories": "Course categories", + "confirmselfenrol": "Are you sure you want to enrol yourself in this course?", + "courses": "Courses", + "downloadcourses": "Download courses", + "enrolme": "Enrol me", + "errorloadcategories": "An error occurred while loading categories.", + "errorloadcourses": "An error occurred while loading courses.", + "errorsearching": "An error occurred while searching.", + "errorselfenrol": "An error occurred while self enrolling.", + "filtermycourses": "Filter my courses", + "frontpage": "Front page", + "mycourses": "My courses", + "nocourses": "No course information to show.", + "nocoursesyet": "No courses in this category", + "nosearchresults": "There were no results from your search", + "notenroled": "You are not enrolled in this course", + "notenrollable": "You cannot enrol yourself in this course.", + "password": "Enrolment key", + "paymentrequired": "This course requires a payment for entry.", + "paypalaccepted": "PayPal payments accepted", + "search": "Search", + "searchcourses": "Search courses", + "searchcoursesadvice": "You can use the search courses button to find courses to access as a guest or enrol yourself in courses that allow it.", + "selfenrolment": "Self enrolment", + "sendpaymentbutton": "Send payment via PayPal", + "totalcoursesearchresults": "Total courses: {{$a}}" +} \ No newline at end of file diff --git a/src/core/courses/lang/es-mx.json b/src/core/courses/lang/es-mx.json new file mode 100644 index 000000000..1363fb2cc --- /dev/null +++ b/src/core/courses/lang/es-mx.json @@ -0,0 +1,30 @@ +{ + "allowguests": "Este curso permite la entrada de invitados", + "availablecourses": "Cursos disponibles", + "cannotretrievemorecategories": "No se pueden recuperar categorías más profundas que el nivel {{$a}}.", + "categories": "Categorías", + "confirmselfenrol": "¿Está Usted seguro de querer inscribirse a Usted mismo en este curso?", + "courses": "Cursos", + "enrolme": "Inscribirme", + "errorloadcategories": "Ocurrió un error al cargar categorías.", + "errorloadcourses": "Ocurrió un error al cargar los cursos.", + "errorsearching": "Ocurrio un error al buscar.", + "errorselfenrol": "Ocurrio un error al auto-inscribir.", + "filtermycourses": "<< Date: Mon, 11 Dec 2017 14:59:44 +0100 Subject: [PATCH 08/24] MOBILE-2302 courses: Implement course progress component --- src/components/components.module.ts | 7 +- src/components/progress-bar/progress-bar.html | 8 +++ src/components/progress-bar/progress-bar.scss | 53 ++++++++++++++ src/components/progress-bar/progress-bar.ts | 60 ++++++++++++++++ .../course-progress/course-progress.html | 35 +++++++++ .../course-progress/course-progress.scss | 2 + .../course-progress/course-progress.ts | 72 +++++++++++++++++++ src/core/courses/courses.module.ts | 5 ++ src/directives/format-text.ts | 12 ++-- 9 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 src/components/progress-bar/progress-bar.html create mode 100644 src/components/progress-bar/progress-bar.scss create mode 100644 src/components/progress-bar/progress-bar.ts create mode 100644 src/core/courses/components/course-progress/course-progress.html create mode 100644 src/core/courses/components/course-progress/course-progress.scss create mode 100644 src/core/courses/components/course-progress/course-progress.ts diff --git a/src/components/components.module.ts b/src/components/components.module.ts index 33b38d327..30ee33409 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -21,6 +21,7 @@ import { CoreMarkRequiredComponent } from './mark-required/mark-required'; import { CoreInputErrorsComponent } from './input-errors/input-errors'; import { CoreShowPasswordComponent } from './show-password/show-password'; import { CoreIframeComponent } from './iframe/iframe'; +import { CoreProgressBarComponent } from './progress-bar/progress-bar'; @NgModule({ declarations: [ @@ -28,7 +29,8 @@ import { CoreIframeComponent } from './iframe/iframe'; CoreMarkRequiredComponent, CoreInputErrorsComponent, CoreShowPasswordComponent, - CoreIframeComponent + CoreIframeComponent, + CoreProgressBarComponent ], imports: [ IonicModule, @@ -40,7 +42,8 @@ import { CoreIframeComponent } from './iframe/iframe'; CoreMarkRequiredComponent, CoreInputErrorsComponent, CoreShowPasswordComponent, - CoreIframeComponent + CoreIframeComponent, + CoreProgressBarComponent ] }) export class CoreComponentsModule {} diff --git a/src/components/progress-bar/progress-bar.html b/src/components/progress-bar/progress-bar.html new file mode 100644 index 000000000..3cc2055b0 --- /dev/null +++ b/src/components/progress-bar/progress-bar.html @@ -0,0 +1,8 @@ +
+ +
+ +
+
+ {{ 'core.percentagenumber' | translate: {$a: text} }} +
diff --git a/src/components/progress-bar/progress-bar.scss b/src/components/progress-bar/progress-bar.scss new file mode 100644 index 000000000..a1d8ec63d --- /dev/null +++ b/src/components/progress-bar/progress-bar.scss @@ -0,0 +1,53 @@ +$mm-progress-bar-height: 5px !default; + +core-progress-bar { + padding-right: 55px; + position: relative; + display: block; + // @extend .clearfix; + + .mm-progress-text { + margin-left: 10px; + line-height: 35px; + color: $gray-darker; + right: 0; + top: 0; + color: #626262; + position: absolute; + } + + progress { + -webkit-appearance: none; + appearance: none; + height: $mm-progress-bar-height; + margin: 15px 0; + padding: 0; + display: block; + width: 100%; + + .progress-bar-fallback, + &[value]::-webkit-progress-bar { + background-color: $gray-light; + border-radius: 2px; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1) inset; + } + + .progress-bar-fallback span, + &[value]::-webkit-progress-value { + background-color: $mm-color-light; + border-radius: 2px; + } + + .progress-bar-fallback { + width: 100%; + height: $mm-progress-bar-height; + display: block; + position: relative; + + span { + height: $mm-progress-bar-height; + display: block; + } + } + } +} diff --git a/src/components/progress-bar/progress-bar.ts b/src/components/progress-bar/progress-bar.ts new file mode 100644 index 000000000..a77e70291 --- /dev/null +++ b/src/components/progress-bar/progress-bar.ts @@ -0,0 +1,60 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Input, OnChanges, SimpleChange, ChangeDetectionStrategy } from '@angular/core'; +import { DomSanitizer, SafeStyle } from '@angular/platform-browser'; + +/** + * Component to show a progress bar and its value. + * + * Example usage: + * + */ +@Component({ + selector: 'core-progress-bar', + templateUrl: 'progress-bar.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CoreProgressBarComponent implements OnChanges { + @Input() progress: number|string; // Percentage from 0 to 100. + @Input() text?: string; // Percentage in text to be shown at the right. If not defined, progress will be used. + width: SafeStyle; + protected textSupplied = false; + + constructor(private sanitizer: DomSanitizer) {} + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}) { + if (changes.text && typeof changes.text.currentValue != 'undefined') { + // User provided a custom text, don't use default. + this.textSupplied = true; + } + + if (changes.progress) { + // Progress has changed. + this.width = this.sanitizer.bypassSecurityTrustStyle(this.progress + '%'); + if (typeof this.progress == 'string') { + this.progress = parseInt(this.progress, 10); + } + + if (this.progress < 0 || isNaN(this.progress)) { + this.progress = -1; + } else if (!this.textSupplied) { + this.text = String(this.progress); + } + } + } +} diff --git a/src/core/courses/components/course-progress/course-progress.html b/src/core/courses/components/course-progress/course-progress.html new file mode 100644 index 000000000..34c96b8d0 --- /dev/null +++ b/src/core/courses/components/course-progress/course-progress.html @@ -0,0 +1,35 @@ + + +
+
+
{{course.progress}}%
+
+ + + {{course.progress}}% + + + +
+
+
+ +
+
+

+ + + + + +
+ +

+

+ + +

+
+ +
diff --git a/src/core/courses/components/course-progress/course-progress.scss b/src/core/courses/components/course-progress/course-progress.scss new file mode 100644 index 000000000..e24e0ae82 --- /dev/null +++ b/src/core/courses/components/course-progress/course-progress.scss @@ -0,0 +1,2 @@ +core-courses-course-progress { +} diff --git a/src/core/courses/components/course-progress/course-progress.ts b/src/core/courses/components/course-progress/course-progress.ts new file mode 100644 index 000000000..54b5771eb --- /dev/null +++ b/src/core/courses/components/course-progress/course-progress.ts @@ -0,0 +1,72 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Input, OnInit } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreUtilsProvider } from '../../../../providers/utils/utils'; + +/** + * This component is meant to display a course for a list of courses with progress. + * + * Example usage: + * + * + * + */ +@Component({ + selector: 'core-courses-course-progress', + templateUrl: 'course-progress.html' +}) +export class CoreCoursesCourseProgressComponent implements OnInit { + @Input() course: any; // The course to render. + @Input() roundProgress?: boolean|string; // Whether to show the progress. + @Input() showSummary?: boolean|string; // Whether to show the summary. + + actionsLoaded = true; + prefetchCourseIcon: string; + + protected obsStatus; + protected downloadText; + protected downloadingText; + protected downloadButton = { + isDownload: true, + className: 'mm-download-course', + priority: 1000 + }; + protected buttons; + + constructor(private navCtrl: NavController, private translate: TranslateService, private utils: CoreUtilsProvider) { + this.downloadText = this.translate.instant('core.course.downloadcourse'); + this.downloadingText = this.translate.instant('core.downloading'); + } + + /** + * Component being initialized. + */ + ngOnInit() { + // @todo: Handle course prefetch. + // @todo: Handle course handlers (participants, etc.). + this.roundProgress = this.utils.isTrueOrOne(this.roundProgress); + this.showSummary = this.utils.isTrueOrOne(this.showSummary); + } + + /** + * Open a course. + */ + openCourse(course) { + this.navCtrl.push('CoreCourseSectionPage', {course: course}); + } + +} diff --git a/src/core/courses/courses.module.ts b/src/core/courses/courses.module.ts index 96e18c1ec..19beec0fe 100644 --- a/src/core/courses/courses.module.ts +++ b/src/core/courses/courses.module.ts @@ -14,14 +14,19 @@ import { NgModule } from '@angular/core'; import { CoreCoursesProvider } from './providers/courses'; +import { CoreCoursesCourseProgressComponent } from './components/course-progress/course-progress'; @NgModule({ declarations: [ + CoreCoursesCourseProgressComponent ], imports: [ ], providers: [ CoreCoursesProvider + ], + exports: [ + CoreCoursesCourseProgressComponent ] }) export class CoreCoursesModule {} diff --git a/src/directives/format-text.ts b/src/directives/format-text.ts index bf034f643..64b3485b0 100644 --- a/src/directives/format-text.ts +++ b/src/directives/format-text.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Directive, ElementRef, Input, OnInit, Output, EventEmitter } from '@angular/core'; +import { Directive, ElementRef, Input, Output, EventEmitter, OnChanges, SimpleChange } from '@angular/core'; import { Platform } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '../providers/app'; @@ -38,7 +38,7 @@ import { CoreExternalContentDirective } from '../directives/external-content'; @Directive({ selector: 'core-format-text' }) -export class CoreFormatTextDirective implements OnInit { +export class CoreFormatTextDirective implements OnChanges { @Input() text: string; // The text to format. @Input() siteId?: string; // Site ID to use. @Input() component?: string; // Component for CoreExternalContentDirective. @@ -68,10 +68,12 @@ export class CoreFormatTextDirective implements OnInit { } /** - * Function executed when the directive is initialized. + * Detect changes on input properties. */ - ngOnInit() : void { - this.formatAndRenderContents(); + ngOnChanges(changes: {[name: string]: SimpleChange}) { + if (changes.text) { + this.formatAndRenderContents(); + } } /** From 2d29cc2da693584e2a574d569bb82fb03f70451a Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 12 Dec 2017 10:49:13 +0100 Subject: [PATCH 09/24] MOBILE-2302 courses: Implement My Courses --- src/app/app.scss | 102 ++++++++++++ src/classes/site.ts | 9 +- src/components/components.module.ts | 7 +- src/components/empty-box/empty-box.html | 8 + src/components/empty-box/empty-box.scss | 3 + src/components/empty-box/empty-box.ts | 33 ++++ .../courses/components/components.module.ts | 40 +++++ src/core/courses/courses.module.ts | 20 ++- .../courses/pages/my-courses/my-courses.html | 28 ++++ .../pages/my-courses/my-courses.module.ts | 33 ++++ .../courses/pages/my-courses/my-courses.scss | 3 + .../courses/pages/my-courses/my-courses.ts | 154 ++++++++++++++++++ src/core/courses/providers/courses.ts | 4 +- src/core/courses/providers/handlers.ts | 65 ++++++++ src/core/mainmenu/pages/menu/menu.ts | 29 +++- src/core/mainmenu/pages/more/more.html | 2 +- src/directives/format-text.ts | 3 +- src/providers/utils/dom.ts | 3 + 18 files changed, 528 insertions(+), 18 deletions(-) create mode 100644 src/components/empty-box/empty-box.html create mode 100644 src/components/empty-box/empty-box.scss create mode 100644 src/components/empty-box/empty-box.ts create mode 100644 src/core/courses/components/components.module.ts create mode 100644 src/core/courses/pages/my-courses/my-courses.html create mode 100644 src/core/courses/pages/my-courses/my-courses.module.ts create mode 100644 src/core/courses/pages/my-courses/my-courses.scss create mode 100644 src/core/courses/pages/my-courses/my-courses.ts create mode 100644 src/core/courses/providers/handlers.ts diff --git a/src/app/app.scss b/src/app/app.scss index 1ef2a9457..215cb71e7 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -113,3 +113,105 @@ ion-avatar ion-img, ion-avatar img { font-style: italic; } +/** Format Text */ +core-format-text[maxHeight], *[core-format-text][maxHeight] { + display: block; + position: relative; + width: 100%; + overflow: hidden; + + /* Force display inline */ + &.inline { + display: inline-block; + width: auto; + } + + // This is to allow clicks in radio/checkbox content. + &.mm-text-formatted { + cursor: pointer; + + .mm-show-more { + display: none; + } + + &:not(.mm-shortened) { + max-height: none !important; + } + + &.mm-shortened { + color: $gray-darker; + overflow: hidden; + min-height: 50px; + + .mm-show-more { + color: color($colors, dark); + text-align: right; + font-size: 14px; + display: block; + position: absolute; + bottom: 0; + right: 0; + z-index: 1001; + background-color: $white; + padding-left: 10px; + + /* @todo + &:after { + @extend .ion; + content: $ionicon-var-chevron-down; + margin-left: 10px; + color: $item-icon-accessory-color; + } + */ + } + + &.mm-expand-in-fullview .mm-show-more:after { + // content: $ionicon-var-chevron-right; @todo + } + + &:before { + content: ''; + height: 100%; + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: -moz-linear-gradient(top, rgba(255, 255, 255, 0) calc(100% - 50px), white calc(100% - 15px)); + background: -webkit-gradient(left top, left bottom, color-stop(calc(100% - 50px), rgba(255, 255, 255, 0)), color-stop(calc(100% - 15px), white)); + background: -webkit-linear-gradient(top, rgba(255, 255, 255, 0) calc(100% - 50px), white calc(100% - 15px)); + background: -o-linear-gradient(top, rgba(255, 255, 255, 0) calc(100% - 50px), white calc(100% - 15px)); + background: -ms-linear-gradient(top, rgba(255, 255, 255, 0) calc(100% - 50px), white calc(100% - 15px)); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0) calc(100% - 50px), white calc(100% - 15px)); + z-index: 1000; + } + } + } +} + +core-format-text, *[core-format-text] { + audio, video, a, iframe { + pointer-events: auto; + } + + // Fix lists styles in core-format-text. + ul, ol { + -webkit-padding-start: 40px; + } + ul { + list-style: disc; + } + ol { + list-style: decimal; + } + + .badge { + position: initial !important; + } +} + +// Message item. +.item-message { + core-format-text > p:only-child { + display: inline; + } +} diff --git a/src/classes/site.ts b/src/classes/site.ts index 901e9d7e7..2b46c8e86 100644 --- a/src/classes/site.ts +++ b/src/classes/site.ts @@ -482,7 +482,14 @@ export class CoreSite { // We pass back a clone of the original object, this may // prevent errors if in the callback the object is modified. - return Object.assign({}, response); + if (typeof response == 'object') { + if (Array.isArray(response)) { + return Array.from(response); + } else { + return Object.assign({}, response); + } + } + return response; }).catch((error) => { if (error.errorcode == 'invalidtoken' || (error.errorcode == 'accessexception' && error.message.indexOf('Invalid token - token expired') > -1)) { diff --git a/src/components/components.module.ts b/src/components/components.module.ts index 30ee33409..ab0b08f84 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -22,6 +22,7 @@ import { CoreInputErrorsComponent } from './input-errors/input-errors'; import { CoreShowPasswordComponent } from './show-password/show-password'; import { CoreIframeComponent } from './iframe/iframe'; import { CoreProgressBarComponent } from './progress-bar/progress-bar'; +import { CoreEmptyBoxComponent } from './empty-box/empty-box'; @NgModule({ declarations: [ @@ -30,7 +31,8 @@ import { CoreProgressBarComponent } from './progress-bar/progress-bar'; CoreInputErrorsComponent, CoreShowPasswordComponent, CoreIframeComponent, - CoreProgressBarComponent + CoreProgressBarComponent, + CoreEmptyBoxComponent ], imports: [ IonicModule, @@ -43,7 +45,8 @@ import { CoreProgressBarComponent } from './progress-bar/progress-bar'; CoreInputErrorsComponent, CoreShowPasswordComponent, CoreIframeComponent, - CoreProgressBarComponent + CoreProgressBarComponent, + CoreEmptyBoxComponent ] }) export class CoreComponentsModule {} diff --git a/src/components/empty-box/empty-box.html b/src/components/empty-box/empty-box.html new file mode 100644 index 000000000..1c43cb9bf --- /dev/null +++ b/src/components/empty-box/empty-box.html @@ -0,0 +1,8 @@ +
+
+ + +

{{ message }}

+ +
+
\ No newline at end of file diff --git a/src/components/empty-box/empty-box.scss b/src/components/empty-box/empty-box.scss new file mode 100644 index 000000000..ec407edf6 --- /dev/null +++ b/src/components/empty-box/empty-box.scss @@ -0,0 +1,3 @@ +core-empty-box { + +} diff --git a/src/components/empty-box/empty-box.ts b/src/components/empty-box/empty-box.ts new file mode 100644 index 000000000..340445940 --- /dev/null +++ b/src/components/empty-box/empty-box.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Input } from '@angular/core'; + +/** + * Component to show an empty box message. It will show an optional icon or image and a text centered on page. + * + * Usage: + * + */ +@Component({ + selector: 'core-empty-box', + templateUrl: 'empty-box.html' +}) +export class CoreEmptyBoxComponent { + @Input() message: string; // Message to display. + @Input() icon?: string; // Name of the icon to use. + @Input() image?: string; // Image source. If an icon is provided, image won't be used. + + constructor() {} +} diff --git a/src/core/courses/components/components.module.ts b/src/core/courses/components/components.module.ts new file mode 100644 index 000000000..f6a66ff29 --- /dev/null +++ b/src/core/courses/components/components.module.ts @@ -0,0 +1,40 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '../../../components/components.module'; +import { CoreDirectivesModule } from '../../../directives/directives.module'; +import { CoreCoursesCourseProgressComponent } from '../components/course-progress/course-progress'; + +@NgModule({ + declarations: [ + CoreCoursesCourseProgressComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule + ], + providers: [ + ], + exports: [ + CoreCoursesCourseProgressComponent + ] +}) +export class CoreCoursesComponentsModule {} diff --git a/src/core/courses/courses.module.ts b/src/core/courses/courses.module.ts index 19beec0fe..6cc2b1056 100644 --- a/src/core/courses/courses.module.ts +++ b/src/core/courses/courses.module.ts @@ -14,19 +14,21 @@ import { NgModule } from '@angular/core'; import { CoreCoursesProvider } from './providers/courses'; -import { CoreCoursesCourseProgressComponent } from './components/course-progress/course-progress'; +import { CoreCoursesMainMenuHandler } from './providers/handlers'; +import { CoreMainMenuDelegate } from '../mainmenu/providers/delegate'; @NgModule({ - declarations: [ - CoreCoursesCourseProgressComponent - ], + declarations: [], imports: [ ], providers: [ - CoreCoursesProvider + CoreCoursesProvider, + CoreCoursesMainMenuHandler ], - exports: [ - CoreCoursesCourseProgressComponent - ] + exports: [] }) -export class CoreCoursesModule {} +export class CoreCoursesModule { + constructor(mainMenuDelegate: CoreMainMenuDelegate, mainMenuHandler: CoreCoursesMainMenuHandler) { + mainMenuDelegate.registerHandler(mainMenuHandler); + } +} diff --git a/src/core/courses/pages/my-courses/my-courses.html b/src/core/courses/pages/my-courses/my-courses.html new file mode 100644 index 000000000..720c27686 --- /dev/null +++ b/src/core/courses/pages/my-courses/my-courses.html @@ -0,0 +1,28 @@ + + + {{ 'core.courses.mycourses' | translate }} + + + + + + + + + + + + + + + + + + + +

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

+
+
+
diff --git a/src/core/courses/pages/my-courses/my-courses.module.ts b/src/core/courses/pages/my-courses/my-courses.module.ts new file mode 100644 index 000000000..45c404a78 --- /dev/null +++ b/src/core/courses/pages/my-courses/my-courses.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreCoursesMyCoursesPage } from './my-courses'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; + +@NgModule({ + declarations: [ + CoreCoursesMyCoursesPage, + ], + imports: [ + CoreComponentsModule, + CoreCoursesComponentsModule, + IonicPageModule.forChild(CoreCoursesMyCoursesPage), + TranslateModule.forChild() + ], +}) +export class CoreCoursesMyCoursesPageModule {} diff --git a/src/core/courses/pages/my-courses/my-courses.scss b/src/core/courses/pages/my-courses/my-courses.scss new file mode 100644 index 000000000..89812f8f7 --- /dev/null +++ b/src/core/courses/pages/my-courses/my-courses.scss @@ -0,0 +1,3 @@ +page-core-courses-my-courses { + +} diff --git a/src/core/courses/pages/my-courses/my-courses.ts b/src/core/courses/pages/my-courses/my-courses.ts new file mode 100644 index 000000000..2b87258e8 --- /dev/null +++ b/src/core/courses/pages/my-courses/my-courses.ts @@ -0,0 +1,154 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, NavController } from 'ionic-angular'; +import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreCoursesProvider } from '../../providers/courses'; + +/** + * Page that displays the list of courses the user is enrolled in. + */ +@IonicPage() +@Component({ + selector: 'page-core-courses-my-courses', + templateUrl: 'my-courses.html', +}) +export class CoreCoursesMyCoursesPage { + courses: any[]; + filteredCourses: any[]; + searchEnabled: boolean; + filter = ''; + showFilter = false; + coursesLoaded = false; + + protected prefetchIconInitialized = false; + protected myCoursesObserver; + protected siteUpdatedObserver; + + constructor(private navCtrl: NavController, private coursesProvider: CoreCoursesProvider, + private domUtils: CoreDomUtilsProvider, private eventsProvider: CoreEventsProvider, + private sitesProvider: CoreSitesProvider) {} + + /** + * View loaded. + */ + ionViewDidLoad() { + this.searchEnabled = !this.coursesProvider.isSearchCoursesDisabledInSite(); + + this.fetchCourses().finally(() => { + this.coursesLoaded = true; + }); + + this.myCoursesObserver = this.eventsProvider.on(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, (data) => { + if (data.siteId == this.sitesProvider.getCurrentSiteId()) { + this.fetchCourses(); + } + }); + + this.siteUpdatedObserver = this.eventsProvider.on(CoreEventsProvider.SITE_UPDATED, (data) => { + if (data.siteId == this.sitesProvider.getCurrentSiteId()) { + this.searchEnabled = !this.coursesProvider.isSearchCoursesDisabledInSite(); + } + }); + } + + /** + * Fetch the user courses. + */ + protected fetchCourses() { + return this.coursesProvider.getUserCourses().then((courses) => { + + const courseIds = courses.map((course) => { + return course.id; + }); + + return this.coursesProvider.getCoursesOptions(courseIds).then((options) => { + courses.forEach((course) => { + course.progress = isNaN(parseInt(course.progress, 10)) ? false : parseInt(course.progress, 10); + course.navOptions = options.navOptions[course.id]; + course.admOptions = options.admOptions[course.id]; + }); + this.courses = courses; + this.filteredCourses = this.courses; + this.filter = ''; + + // this.initPrefetchCoursesIcon(); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.courses.errorloadcourses', true); + }); + } + + /** + * Refresh the courses. + * + * @param {any} refresher Refresher. + */ + refreshCourses(refresher: any) { + let promises = []; + + promises.push(this.coursesProvider.invalidateUserCourses()); + // promises.push($mmCoursesDelegate.clearAndInvalidateCoursesOptions()); + + Promise.all(promises).finally(() => { + + this.prefetchIconInitialized = false; + this.fetchCourses().finally(() => { + refresher.complete(); + }); + }); + } + + /** + * Show or hide the filter. + */ + switchFilter() { + this.filter = ''; + this.showFilter = !this.showFilter; + this.filteredCourses = this.courses; + } + + /** + * Go to search courses. + */ + openSearch() { + this.navCtrl.push('CoreCoursesSearchPage'); + } + + /** + * The filter has changed. + * + * @param {string} newValue New filter value. + */ + filterChanged(newValue: string) { + if (!newValue || !this.courses) { + this.filteredCourses = this.courses; + } else { + this.filteredCourses = this.courses.filter((course) => { + return course.fullname.indexOf(newValue) > -1; + }); + } + } + + /** + * Page destroyed. + */ + ngOnDestroy() { + this.myCoursesObserver && this.myCoursesObserver.off(); + this.siteUpdatedObserver && this.siteUpdatedObserver.off(); + } +} diff --git a/src/core/courses/providers/courses.ts b/src/core/courses/providers/courses.ts index fb2cd80ee..655f292b7 100644 --- a/src/core/courses/providers/courses.ts +++ b/src/core/courses/providers/courses.ts @@ -144,7 +144,7 @@ export class CoreCoursesProvider { * @param {CoreSite} [site] Site. If not defined, use current site. * @return {boolean} Whether it's disabled. */ - isMyCoursesDisabledInSite(site: CoreSite) : boolean { + isMyCoursesDisabledInSite(site?: CoreSite) : boolean { site = site || this.sitesProvider.getCurrentSite(); return site.isFeatureDisabled('$mmSideMenuDelegate_mmCourses'); } @@ -167,7 +167,7 @@ export class CoreCoursesProvider { * @param {CoreSite} [site] Site. If not defined, use current site. * @return {boolean} Whether it's disabled. */ - isSearchCoursesDisabledInSite(site: CoreSite) : boolean { + isSearchCoursesDisabledInSite(site?: CoreSite) : boolean { site = site || this.sitesProvider.getCurrentSite(); return site.isFeatureDisabled('$mmCoursesDelegate_search'); } diff --git a/src/core/courses/providers/handlers.ts b/src/core/courses/providers/handlers.ts new file mode 100644 index 000000000..cece22a00 --- /dev/null +++ b/src/core/courses/providers/handlers.ts @@ -0,0 +1,65 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Injectable } from '@angular/core'; +import { CoreCoursesProvider } from './courses'; +import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '../../mainmenu/providers/delegate'; + +/** + * Handler to inject an option into main menu. + */ +@Injectable() +export class CoreCoursesMainMenuHandler implements CoreMainMenuHandler { + name = 'mmCourses'; + priority = 1100; + + constructor(private coursesProvider: CoreCoursesProvider) {} + + /** + * Check if the handler is enabled on a site level. + * + * @return {boolean} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean|Promise { + let myCoursesDisabled = this.coursesProvider.isMyCoursesDisabledInSite(); + + // Check if overview side menu is available, so it won't show My courses. + // var $mmaMyOverview = $mmAddonManager.get('$mmaMyOverview'); + // if ($mmaMyOverview) { + // return $mmaMyOverview.isSideMenuAvailable().then(function(enabled) { + // if (enabled) { + // return false; + // } + // // Addon not enabled, check my courses. + // return !myCoursesDisabled; + // }); + // } + // Addon not present, check my courses. + return !myCoursesDisabled; + } + + /** + * Returns the data needed to render the handler. + * + * @return {CoreMainMenuHandlerData} Data needed to render the handler. + */ + getDisplayData(): CoreMainMenuHandlerData { + return { + icon: 'ionic', + title: 'core.courses.mycourses', + page: 'CoreCoursesMyCoursesPage', + class: 'mm-mycourses-handler' + }; + } +} diff --git a/src/core/mainmenu/pages/menu/menu.ts b/src/core/mainmenu/pages/menu/menu.ts index e9bab31e5..57932bbaa 100644 --- a/src/core/mainmenu/pages/menu/menu.ts +++ b/src/core/mainmenu/pages/menu/menu.ts @@ -28,7 +28,7 @@ import { CoreMainMenuDelegate, CoreMainMenuHandlerData } from '../../providers/d templateUrl: 'menu.html', }) export class CoreMainMenuPage implements OnDestroy { - tabs: CoreMainMenuHandlerData[]; + tabs: CoreMainMenuHandlerData[] = []; loaded: boolean; protected subscription; protected moreTabData = { @@ -36,6 +36,7 @@ export class CoreMainMenuPage implements OnDestroy { title: 'core.more', icon: 'more' }; + protected moreTabAdded = false; protected logoutObserver; constructor(private menuDelegate: CoreMainMenuDelegate, private sitesProvider: CoreSitesProvider, @@ -58,7 +59,31 @@ export class CoreMainMenuPage implements OnDestroy { this.subscription = this.menuDelegate.getHandlers().subscribe((handlers) => { this.tabs = handlers.slice(0, CoreMainMenuProvider.NUM_MAIN_HANDLERS); // Get main handlers. - this.tabs.push(this.moreTabData); // Add "More" tab. + + // Check if handlers are already in tabs. Add the ones that aren't. + // @todo: https://github.com/ionic-team/ionic/issues/13633 + for (let i in handlers) { + let handler = handlers[i], + found = false; + + for (let j in this.tabs) { + let tab = this.tabs[j]; + if (tab.title == handler.title && tab.icon == handler.icon) { + found = true; + break; + } + } + + if (!found) { + this.tabs.push(handler); + } + } + + if (!this.moreTabAdded) { + this.moreTabAdded = true; + this.tabs.push(this.moreTabData); // Add "More" tab. + } + this.loaded = this.menuDelegate.areHandlersLoaded(); }); } diff --git a/src/core/mainmenu/pages/more/more.html b/src/core/mainmenu/pages/more/more.html index d1c02dc11..56b29de6d 100644 --- a/src/core/mainmenu/pages/more/more.html +++ b/src/core/mainmenu/pages/more/more.html @@ -1,6 +1,6 @@ - {{ siteInfo.sitename }} + diff --git a/src/directives/format-text.ts b/src/directives/format-text.ts index 64b3485b0..96097e12f 100644 --- a/src/directives/format-text.ts +++ b/src/directives/format-text.ts @@ -177,7 +177,8 @@ export class CoreFormatTextDirective implements OnChanges { if (expandInFullview) { this.element.classList.add('mm-expand-in-fullview'); } - this.element.classList.add('mm-text-formatted mm-shortened'); + this.element.classList.add('mm-text-formatted'); + this.element.classList.add('mm-shortened'); this.element.style.maxHeight = this.maxHeight + 'px'; this.element.addEventListener('click', (e) => { diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index 1feaff433..59d48c641 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -726,6 +726,9 @@ export class CoreDomUtilsProvider { */ showErrorModalDefault(error: any, defaultError: any, needsTranslate?: boolean, autocloseTime?: number) : Alert { if (error != CoreConstants.dontShowError) { + if (error && typeof error != 'string') { + error = error.message || error.error; + } error = typeof error == 'string' ? error : defaultError; return this.showErrorModal(error, needsTranslate, autocloseTime); } From 0c69cbddad78473543e47ca333b91c236a14cf53 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 12 Dec 2017 15:28:01 +0100 Subject: [PATCH 10/24] MOBILE-2302 courses: Implement search courses --- src/assets/img/icons/paypal.png | Bin 0 -> 7181 bytes src/components/components.module.ts | 7 +- src/components/search-box/search-box.html | 10 +++ src/components/search-box/search-box.scss | 6 ++ src/components/search-box/search-box.ts | 67 ++++++++++++++ .../courses/components/components.module.ts | 7 +- .../course-list-item/course-list-item.html | 15 ++++ .../course-list-item/course-list-item.scss | 6 ++ .../course-list-item/course-list-item.ts | 82 ++++++++++++++++++ src/core/courses/pages/search/search.html | 18 ++++ .../courses/pages/search/search.module.ts | 33 +++++++ src/core/courses/pages/search/search.scss | 3 + src/core/courses/pages/search/search.ts | 82 ++++++++++++++++++ src/core/login/pages/site/site.html | 2 +- src/directives/auto-focus.ts | 25 +++++- 15 files changed, 354 insertions(+), 9 deletions(-) create mode 100644 src/assets/img/icons/paypal.png create mode 100644 src/components/search-box/search-box.html create mode 100644 src/components/search-box/search-box.scss create mode 100644 src/components/search-box/search-box.ts create mode 100644 src/core/courses/components/course-list-item/course-list-item.html create mode 100644 src/core/courses/components/course-list-item/course-list-item.scss create mode 100644 src/core/courses/components/course-list-item/course-list-item.ts create mode 100644 src/core/courses/pages/search/search.html create mode 100644 src/core/courses/pages/search/search.module.ts create mode 100644 src/core/courses/pages/search/search.scss create mode 100644 src/core/courses/pages/search/search.ts diff --git a/src/assets/img/icons/paypal.png b/src/assets/img/icons/paypal.png new file mode 100644 index 0000000000000000000000000000000000000000..058f6e4cb52350ea214da2e444f36ef56b7eecbb GIT binary patch literal 7181 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 literal 0 HcmV?d00001 diff --git a/src/components/components.module.ts b/src/components/components.module.ts index ab0b08f84..c814ce0ec 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -23,6 +23,7 @@ import { CoreShowPasswordComponent } from './show-password/show-password'; import { CoreIframeComponent } from './iframe/iframe'; import { CoreProgressBarComponent } from './progress-bar/progress-bar'; import { CoreEmptyBoxComponent } from './empty-box/empty-box'; +import { CoreSearchBoxComponent } from './search-box/search-box'; @NgModule({ declarations: [ @@ -32,7 +33,8 @@ import { CoreEmptyBoxComponent } from './empty-box/empty-box'; CoreShowPasswordComponent, CoreIframeComponent, CoreProgressBarComponent, - CoreEmptyBoxComponent + CoreEmptyBoxComponent, + CoreSearchBoxComponent ], imports: [ IonicModule, @@ -46,7 +48,8 @@ import { CoreEmptyBoxComponent } from './empty-box/empty-box'; CoreShowPasswordComponent, CoreIframeComponent, CoreProgressBarComponent, - CoreEmptyBoxComponent + CoreEmptyBoxComponent, + CoreSearchBoxComponent ] }) export class CoreComponentsModule {} diff --git a/src/components/search-box/search-box.html b/src/components/search-box/search-box.html new file mode 100644 index 000000000..bc0b8a5da --- /dev/null +++ b/src/components/search-box/search-box.html @@ -0,0 +1,10 @@ + +
+ + + + +
+
diff --git a/src/components/search-box/search-box.scss b/src/components/search-box/search-box.scss new file mode 100644 index 000000000..d451c7559 --- /dev/null +++ b/src/components/search-box/search-box.scss @@ -0,0 +1,6 @@ +core-search-box { + .button.item-button[icon-only] { + margin: 0; + padding: ($content-padding / 2) $content-padding; + } +} diff --git a/src/components/search-box/search-box.ts b/src/components/search-box/search-box.ts new file mode 100644 index 000000000..a6e70b3f9 --- /dev/null +++ b/src/components/search-box/search-box.ts @@ -0,0 +1,67 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Input, Output, EventEmitter, OnInit } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreUtilsProvider } from '../../providers/utils/utils'; + +/** + * Component to display a "search box". + * + * @description + * This component will display a standalone search box with its search button in order to have a better UX. + * + * Example usage: + * + */ +@Component({ + selector: 'core-search-box', + templateUrl: 'search-box.html' +}) +export class CoreSearchBoxComponent implements OnInit { + @Input() initialValue?: string = ''; // Initial value for search text. + @Input() searchLabel?: string ; // Label to be used on action button. + @Input() placeholder?: string; // Placeholder text for search text input. + @Input() autocorrect?: string = 'on'; // Enables/disable Autocorrection on search text input. + @Input() spellcheck?: string|boolean = true; // Enables/disable Spellchecker on search text input. + @Input() autoFocus?: string|boolean; // Enables/disable Autofocus when entering view. + @Input() lengthCheck?: number = 3; // Check value length before submit. If 0, any string will be submitted. + @Output() onSubmit: EventEmitter; // Send data when submitting the search form. + + constructor(private translate: TranslateService, private utils: CoreUtilsProvider) { + this.onSubmit = new EventEmitter(); + } + + ngOnInit() { + this.searchLabel = this.searchLabel || this.translate.instant('core.search'); + this.placeholder = this.placeholder || this.translate.instant('core.search'); + this.spellcheck = this.utils.isTrueOrOne(this.spellcheck); + } + + /** + * Form submitted. + * + * @param {string} value Entered value. + */ + submitForm(value: string) { + if (value.length < this.lengthCheck) { + // The view should handle this case, but we check it here too just in case. + return; + } + + this.onSubmit.emit(value); + } + +} diff --git a/src/core/courses/components/components.module.ts b/src/core/courses/components/components.module.ts index f6a66ff29..6cddb3414 100644 --- a/src/core/courses/components/components.module.ts +++ b/src/core/courses/components/components.module.ts @@ -19,10 +19,12 @@ import { TranslateModule } from '@ngx-translate/core'; import { CoreComponentsModule } from '../../../components/components.module'; import { CoreDirectivesModule } from '../../../directives/directives.module'; import { CoreCoursesCourseProgressComponent } from '../components/course-progress/course-progress'; +import { CoreCoursesCourseListItemComponent } from '../components/course-list-item/course-list-item'; @NgModule({ declarations: [ - CoreCoursesCourseProgressComponent + CoreCoursesCourseProgressComponent, + CoreCoursesCourseListItemComponent ], imports: [ CommonModule, @@ -34,7 +36,8 @@ import { CoreCoursesCourseProgressComponent } from '../components/course-progres providers: [ ], exports: [ - CoreCoursesCourseProgressComponent + CoreCoursesCourseProgressComponent, + CoreCoursesCourseListItemComponent ] }) export class CoreCoursesComponentsModule {} diff --git a/src/core/courses/components/course-list-item/course-list-item.html b/src/core/courses/components/course-list-item/course-list-item.html new file mode 100644 index 000000000..1c105977b --- /dev/null +++ b/src/core/courses/components/course-list-item/course-list-item.html @@ -0,0 +1,15 @@ + + +

+
+ + + + + + + + + +
+
diff --git a/src/core/courses/components/course-list-item/course-list-item.scss b/src/core/courses/components/course-list-item/course-list-item.scss new file mode 100644 index 000000000..2487cb6e9 --- /dev/null +++ b/src/core/courses/components/course-list-item/course-list-item.scss @@ -0,0 +1,6 @@ +core-courses-course-list-item { + .mm-course-enrollment-img { + max-width: 16px; + max-height: 16px; + } +} diff --git a/src/core/courses/components/course-list-item/course-list-item.ts b/src/core/courses/components/course-list-item/course-list-item.ts new file mode 100644 index 000000000..9e67707f1 --- /dev/null +++ b/src/core/courses/components/course-list-item/course-list-item.ts @@ -0,0 +1,82 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Input, OnInit } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreCoursesProvider } from '../../providers/courses'; + +/** + * This directive is meant to display an item for a list of courses. + * + * Example usage: + * + * + */ +@Component({ + selector: 'core-courses-course-list-item', + templateUrl: 'course-list-item.html' +}) +export class CoreCoursesCourseListItemComponent implements OnInit { + @Input() course: any; // The course to render. + + constructor(private navCtrl: NavController, private translate: TranslateService, private coursesProvider: CoreCoursesProvider) {} + + /** + * Component being initialized. + */ + ngOnInit() { + // Check if the user is enrolled in the course. + return this.coursesProvider.getUserCourse(this.course.id).then(() => { + this.course.isEnrolled = true; + }).catch(() => { + this.course.isEnrolled = false; + this.course.enrollment = []; + + this.course.enrollmentmethods.forEach((instance) => { + if (instance === 'self') { + this.course.enrollment.push({ + name: this.translate.instant('core.courses.selfenrolment'), + icon: 'unlock' + }); + } else if (instance === 'guest') { + this.course.enrollment.push({ + name: this.translate.instant('core.courses.allowguests'), + icon: 'person' + }); + } else if (instance === 'paypal') { + this.course.enrollment.push({ + name: this.translate.instant('core.courses.paypalaccepted'), + img: 'assets/img/icons/paypal.png' + }); + } + }); + + if (this.course.enrollment.length == 0) { + this.course.enrollment.push({ + name: this.translate.instant('core.courses.notenrollable'), + icon: 'lock' + }); + } + }); + } + + /** + * Open a course. + */ + openCourse(course) { + this.navCtrl.push('CoreCoursesCoursePreviewPage', {course: course}); + } + +} diff --git a/src/core/courses/pages/search/search.html b/src/core/courses/pages/search/search.html new file mode 100644 index 000000000..4df0a3845 --- /dev/null +++ b/src/core/courses/pages/search/search.html @@ -0,0 +1,18 @@ + + + {{ 'core.courses.searchcourses' | translate }} + + + + + +
+ {{ 'core.courses.totalcoursesearchresults' | translate:{$a: total} }} + + + + + +
+
+ diff --git a/src/core/courses/pages/search/search.module.ts b/src/core/courses/pages/search/search.module.ts new file mode 100644 index 000000000..c74212c21 --- /dev/null +++ b/src/core/courses/pages/search/search.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreCoursesSearchPage } from './search'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; + +@NgModule({ + declarations: [ + CoreCoursesSearchPage, + ], + imports: [ + CoreComponentsModule, + CoreCoursesComponentsModule, + IonicPageModule.forChild(CoreCoursesSearchPage), + TranslateModule.forChild() + ], +}) +export class CoreCoursesSearchPageModule {} diff --git a/src/core/courses/pages/search/search.scss b/src/core/courses/pages/search/search.scss new file mode 100644 index 000000000..1bf3fe798 --- /dev/null +++ b/src/core/courses/pages/search/search.scss @@ -0,0 +1,3 @@ +page-core-courses-search { + +} diff --git a/src/core/courses/pages/search/search.ts b/src/core/courses/pages/search/search.ts new file mode 100644 index 000000000..654b36cb5 --- /dev/null +++ b/src/core/courses/pages/search/search.ts @@ -0,0 +1,82 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreCoursesProvider } from '../../providers/courses'; + +/** + * Page that allows searching for courses. + */ +@IonicPage() +@Component({ + selector: 'page-core-courses-search', + templateUrl: 'search.html', +}) +export class CoreCoursesSearchPage { + total = 0; + courses: any[]; + canLoadMore: boolean; + + protected page = 0; + protected currentSearch = ''; + + constructor(private domUtils: CoreDomUtilsProvider, private coursesProvider: CoreCoursesProvider) {} + + /** + * Search a new text. + * + * @param {string} text The text to search. + */ + search(text: string) { + this.currentSearch = text; + this.courses = undefined; + this.page = 0; + + let modal = this.domUtils.showModalLoading('core.searching', true); + this.searchCourses().finally(() => { + modal.dismiss(); + }); + } + + /** + * Load more results. + */ + loadMoreResults(infiniteScroll) { + this.searchCourses().finally(() => { + infiniteScroll.complete(); + }); + } + + /** + * Search courses or load the next page of current search. + */ + protected searchCourses() { + return this.coursesProvider.search(this.currentSearch, this.page).then((response) => { + if (this.page === 0) { + this.courses = response.courses; + } else { + this.courses = this.courses.concat(response.courses); + } + this.total = response.total; + + this.page++; + this.canLoadMore = this.courses.length < this.total; + }).catch((error) => { + this.canLoadMore = false; + this.domUtils.showErrorModalDefault(error, 'core.courses.errorsearching', true); + }); + } +} diff --git a/src/core/login/pages/site/site.html b/src/core/login/pages/site/site.html index d8f15beac..dc5c1dda1 100644 --- a/src/core/login/pages/site/site.html +++ b/src/core/login/pages/site/site.html @@ -19,7 +19,7 @@

{{ 'core.login.newsitedescription' | translate }}

- +
diff --git a/src/directives/auto-focus.ts b/src/directives/auto-focus.ts index 5cded27fa..9b41e7a3c 100644 --- a/src/directives/auto-focus.ts +++ b/src/directives/auto-focus.ts @@ -12,7 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Directive, Input, AfterViewInit, ElementRef } from '@angular/core'; +import { Directive, Input, OnInit, ElementRef } from '@angular/core'; +import { NavController } from 'ionic-angular'; import { CoreDomUtilsProvider } from '../providers/utils/dom'; import { CoreUtilsProvider } from '../providers/utils/utils'; @@ -24,19 +25,35 @@ import { CoreUtilsProvider } from '../providers/utils/utils'; @Directive({ selector: '[core-auto-focus]' }) -export class CoreAutoFocusDirective implements AfterViewInit { +export class CoreAutoFocusDirective implements OnInit { @Input('core-auto-focus') coreAutoFocus: boolean|string = true; protected element: HTMLElement; - constructor(element: ElementRef, private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider) { + constructor(element: ElementRef, private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider, + private navCtrl: NavController) { this.element = element.nativeElement || element; } + /** + * Component being initialized. + */ + ngOnInit() { + if (this.navCtrl.isTransitioning()) { + // Navigating to a new page. Wait for the transition to be over. + let subscription = this.navCtrl.viewDidEnter.subscribe(() => { + this.autoFocus(); + subscription.unsubscribe(); + }); + } else { + this.autoFocus(); + } + } + /** * Function after the view is initialized. */ - ngAfterViewInit() { + protected autoFocus() { const autoFocus = this.utils.isTrueOrOne(this.coreAutoFocus); if (autoFocus) { // If it's a ion-input or ion-textarea, search the right input to use. From 2b8b5b8b878182a53365071a6381e02ef863b099 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 13 Dec 2017 11:04:37 +0100 Subject: [PATCH 11/24] MOBILE-2302 courses: Implement course preview --- src/app/app.scss | 1 + .../pages/course-preview/course-preview.html | 66 +++ .../course-preview/course-preview.module.ts | 35 ++ .../pages/course-preview/course-preview.scss | 3 + .../pages/course-preview/course-preview.ts | 396 ++++++++++++++++++ .../courses/pages/my-courses/my-courses.ts | 4 +- .../self-enrol-password.html | 23 + .../self-enrol-password.module.ts | 33 ++ .../self-enrol-password.ts | 44 ++ src/core/courses/providers/courses.ts | 1 + 10 files changed, 604 insertions(+), 2 deletions(-) create mode 100644 src/core/courses/pages/course-preview/course-preview.html create mode 100644 src/core/courses/pages/course-preview/course-preview.module.ts create mode 100644 src/core/courses/pages/course-preview/course-preview.scss create mode 100644 src/core/courses/pages/course-preview/course-preview.ts create mode 100644 src/core/courses/pages/self-enrol-password/self-enrol-password.html create mode 100644 src/core/courses/pages/self-enrol-password/self-enrol-password.module.ts create mode 100644 src/core/courses/pages/self-enrol-password/self-enrol-password.ts diff --git a/src/app/app.scss b/src/app/app.scss index 215cb71e7..56f3c6d57 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -42,6 +42,7 @@ // This is done for accessibility reasons when a heading is semantically incorrect. .item .item-heading { @extend h6; + margin: 0; } .mm-oauth-icon, .item.mm-oauth-icon, .list .item.mm-oauth-icon { diff --git a/src/core/courses/pages/course-preview/course-preview.html b/src/core/courses/pages/course-preview/course-preview.html new file mode 100644 index 000000000..143e07f10 --- /dev/null +++ b/src/core/courses/pages/course-preview/course-preview.html @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + +

+

{{course.categoryname}}

+

{{course.startdate * 1000 | coreFormatDate:"dfdaymonthyear"}} - {{course.enddate * 1000 | coreFormatDate:"dfdaymonthyear"}}

+
+ + + + + + +

{{ 'core.teachers' | translate }}

+

{{contact.fullname}}

+
+ +
+ +

{{ instance.name }}

+ +
+
+ +

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

+

{{ 'core.paymentinstant' | translate }}

+ +
+ +

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

+
+ + + +

{{ 'core.course.contents' | translate }}

+ +
+ + + + +
+
+
diff --git a/src/core/courses/pages/course-preview/course-preview.module.ts b/src/core/courses/pages/course-preview/course-preview.module.ts new file mode 100644 index 000000000..00e38e14b --- /dev/null +++ b/src/core/courses/pages/course-preview/course-preview.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreCoursesCoursePreviewPage } from './course-preview'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; +import { CorePipesModule } from '../../../../pipes/pipes.module'; + +@NgModule({ + declarations: [ + CoreCoursesCoursePreviewPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + IonicPageModule.forChild(CoreCoursesCoursePreviewPage), + TranslateModule.forChild() + ], +}) +export class CoreCoursesCoursePreviewPageModule {} diff --git a/src/core/courses/pages/course-preview/course-preview.scss b/src/core/courses/pages/course-preview/course-preview.scss new file mode 100644 index 000000000..6667a1983 --- /dev/null +++ b/src/core/courses/pages/course-preview/course-preview.scss @@ -0,0 +1,3 @@ +page-core-courses-course-preview { + +} diff --git a/src/core/courses/pages/course-preview/course-preview.ts b/src/core/courses/pages/course-preview/course-preview.ts new file mode 100644 index 000000000..2171fc0d3 --- /dev/null +++ b/src/core/courses/pages/course-preview/course-preview.ts @@ -0,0 +1,396 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, OnDestroy } from '@angular/core'; +import { IonicPage, NavController, NavParams, Platform, ModalController, Modal } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '../../../../providers/app'; +import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; +import { CoreCoursesProvider } from '../../providers/courses'; + +/** + * Page that allows "previewing" a course and enrolling in it if enabled and not enrolled. + */ +@IonicPage() +@Component({ + selector: 'page-core-courses-course-preview', + templateUrl: 'course-preview.html', +}) +export class CoreCoursesCoursePreviewPage implements OnDestroy { + course: any; + isEnrolled: boolean; + handlersShouldBeShown: boolean = true; + handlersLoaded: boolean; + component = 'CoreCoursesCoursePreview'; + selfEnrolInstances: any[] = []; + paypalEnabled: boolean; + dataLoaded: boolean; + prefetchCourseIcon: string; + + protected guestWSAvailable: boolean; + protected isGuestEnabled: boolean = false; + protected guestInstanceId: number; + protected enrollmentMethods: any[]; + protected waitStart = 0; + protected enrolUrl: string; + protected courseUrl: string; + protected paypalReturnUrl: string; + protected isMobile: boolean; + protected isDesktop: boolean; + protected selfEnrolModal: Modal; + protected pageDestroyed = false; + protected currentInstanceId: number; + + constructor(private navCtrl: NavController, navParams: NavParams, private sitesProvider: CoreSitesProvider, + private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider, appProvider: CoreAppProvider, + private coursesProvider: CoreCoursesProvider, private platform: Platform, private modalCtrl: ModalController, + private translate: TranslateService, private eventsProvider: CoreEventsProvider) { + this.course = navParams.get('course'); + this.isMobile = appProvider.isMobile(); + this.isDesktop = appProvider.isDesktop(); + } + + /** + * View loaded. + */ + ionViewDidLoad() { + const currentSite = this.sitesProvider.getCurrentSite(), + currentSiteUrl = currentSite && currentSite.getURL(); + + this.paypalEnabled = this.course.enrollmentmethods && this.course.enrollmentmethods.indexOf('paypal') > -1; + this.guestWSAvailable = this.coursesProvider.isGuestWSAvailable(); + this.enrolUrl = this.textUtils.concatenatePaths(currentSiteUrl, 'enrol/index.php?id=' + this.course.id); + this.courseUrl = this.textUtils.concatenatePaths(currentSiteUrl, 'course/view.php?id=' + this.course.id); + this.paypalReturnUrl = this.textUtils.concatenatePaths(currentSiteUrl, 'enrol/paypal/return.php'); + + // Initialize the self enrol modal. + this.selfEnrolModal = this.modalCtrl.create('CoreCoursesSelfEnrolPasswordPage'); + this.selfEnrolModal.onDidDismiss((password: string) => { + if (typeof password != 'undefined') { + this.selfEnrolInCourse(password, this.currentInstanceId); + } + }); + + this.getCourse().finally(() => { + // @todo: Prefetch course. + }); + } + + /** + * Page destroyed. + */ + ngOnDestroy() { + this.pageDestroyed = true; + } + + /** + * Check if the user can access as guest. + * + * @return {Promise} Promise resolved if can access as guest, rejected otherwise. Resolve param indicates if + * password is required for guest access. + */ + protected canAccessAsGuest() : Promise { + if (!this.isGuestEnabled) { + return Promise.reject(null); + } + + // Search instance ID of guest enrolment method. + this.guestInstanceId = undefined; + for (let i = 0; i < this.enrollmentMethods.length; i++) { + let method = this.enrollmentMethods[i]; + if (method.type == 'guest') { + this.guestInstanceId = method.id; + break; + } + } + + if (this.guestInstanceId) { + return this.coursesProvider.getCourseGuestEnrolmentInfo(this.guestInstanceId).then((info) => { + if (!info.status) { + // Not active, reject. + return Promise.reject(null); + } + return info.passwordrequired; + }); + } + + return Promise.reject(null); + } + + /** + * Convenience function to get course. We use this to determine if a user can see the course or not. + * + * @param {boolean} refresh Whether the user is refreshing the data. + */ + protected getCourse(refresh?: boolean) : Promise { + // Get course enrolment methods. + this.selfEnrolInstances = []; + return this.coursesProvider.getCourseEnrolmentMethods(this.course.id).then((methods) => { + this.enrollmentMethods = methods; + + this.enrollmentMethods.forEach((method) => { + if (method.type === 'self') { + this.selfEnrolInstances.push(method); + } else if (this.guestWSAvailable && method.type === 'guest') { + this.isGuestEnabled = true; + } + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting enrolment data'); + }).then(() => { + // Check if user is enrolled in the course. + return this.coursesProvider.getUserCourse(this.course.id).then((course) => { + this.isEnrolled = true; + return course; + }).catch(() => { + // The user is not enrolled in the course. Use getCourses to see if it's an admin/manager and can see the course. + this.isEnrolled = false; + return this.coursesProvider.getCourse(this.course.id); + }).then((course) => { + // Success retrieving the course, we can assume the user has permissions to view it. + this.course.fullname = course.fullname || this.course.fullname; + this.course.summary = course.summary || this.course.summary; + return this.loadCourseNavHandlers(refresh, false); + }).catch(() => { + // The user is not an admin/manager. Check if we can provide guest access to the course. + return this.canAccessAsGuest().then((passwordRequired) => { + if (!passwordRequired) { + return this.loadCourseNavHandlers(refresh, true); + } else { + return Promise.reject(null); + } + }).catch(() => { + this.course._handlers = []; + this.handlersShouldBeShown = false; + }); + }); + }).finally(() => { + this.dataLoaded = true; + }); + } + + /** + * Load course nav handlers. + * + * @param {boolean} refresh Whether the user is refreshing the data. + * @param {boolean} guest Whether it's guest access. + */ + protected loadCourseNavHandlers(refresh: boolean, guest: boolean) : Promise { + // @todo: Get the handlers to be shown. + return new Promise((resolve, reject) => { + this.course._handlers = []; + this.handlersShouldBeShown = true; + this.handlersLoaded = true; + resolve(); + }); + // return $mmCoursesDelegate.getNavHandlersToDisplay(course, refresh, guest, true).then(function(handlers) { + // course._handlers = handlers; + // $scope.handlersShouldBeShown = true; + // }).catch(() => { + + // }); + } + + /** + * Open the course. + */ + openCourse() { + if (!this.handlersShouldBeShown) { + // Course cannot be opened. + return; + } + + this.navCtrl.push('CoreCourseSectionPage', {course: this.course}); + } + + /** + * Enrol using PayPal. + */ + paypalEnrol() { + let window, + hasReturnedFromPaypal = false, + inAppLoadSubscription, + inAppFinishSubscription, + inAppExitSubscription, + appResumeSubscription, + urlLoaded = (event) => { + if (event.url.indexOf(this.paypalReturnUrl) != -1) { + hasReturnedFromPaypal = true; + } else if (event.url.indexOf(this.courseUrl) != -1 && hasReturnedFromPaypal) { + // User reached the course index page after returning from PayPal, close the InAppBrowser. + inAppClosed(); + window.close(); + } + }, + inAppClosed = () => { + // InAppBrowser closed, refresh data. + unsubscribeAll(); + + if (!this.dataLoaded) { + return; + } + this.dataLoaded = false; + this.refreshData(); + }, + unsubscribeAll = () => { + inAppLoadSubscription && inAppLoadSubscription.unsubscribe(); + inAppFinishSubscription && inAppFinishSubscription.unsubscribe(); + inAppExitSubscription && inAppExitSubscription.unsubscribe(); + appResumeSubscription && appResumeSubscription.unsubscribe(); + }; + + // Open the enrolment page in InAppBrowser. + this.sitesProvider.getCurrentSite().openInAppWithAutoLogin(this.enrolUrl).then((w) => { + window = w; + + if (this.isDesktop || this.isMobile) { + // Observe loaded pages in the InAppBrowser to check if the enrol process has ended. + inAppLoadSubscription = window.on('loadstart').subscribe(urlLoaded); + // Observe window closed. + inAppExitSubscription = window.on('exit').subscribe(inAppClosed); + } + + if (this.isDesktop) { + // In desktop, also observe stop loading since some pages don't throw the loadstart event. + inAppFinishSubscription = window.on('loadstop').subscribe(urlLoaded); + + // Since the user can switch windows, reload the data if he comes back to the app. + appResumeSubscription = this.platform.resume.subscribe(() => { + if (!this.dataLoaded) { + return; + } + this.dataLoaded = false; + this.refreshData(); + }); + } + }); + } + + /** + * User clicked in a self enrol button. + * + * @param {number} instanceId The instance ID of the enrolment method. + */ + selfEnrolClicked(instanceId: number) { + this.domUtils.showConfirm(this.translate.instant('core.courses.confirmselfenrol')).then(() => { + this.selfEnrolInCourse('', instanceId); + }).catch(() => { + // User cancelled. + }); + } + + /** + * Self enrol in a course. + * + * @param {string} password Password to use. + * @param {number} instanceId The instance ID. + * @return {Promise} Promise resolved when self enrolled. + */ + selfEnrolInCourse(password: string, instanceId: number) : Promise { + let modal = this.domUtils.showModalLoading('core.loading', true); + + return this.coursesProvider.selfEnrol(this.course.id, password, instanceId).then(() => { + // Close modal and refresh data. + this.isEnrolled = true; + this.dataLoaded = false; + + // Sometimes the list of enrolled courses takes a while to be updated. Wait for it. + this.waitForEnrolled(true).then(() => { + this.refreshData().finally(() => { + // My courses have been updated, trigger event. + this.eventsProvider.trigger( + CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, {siteId: this.sitesProvider.getCurrentSiteId()}); + }); + }); + }).catch((error) => { + if (error && error.code === CoreCoursesProvider.ENROL_INVALID_KEY) { + // Invalid password, show the modal to enter the password. + this.selfEnrolModal.present(); + this.currentInstanceId = instanceId; + + if (!password) { + // No password entered, don't show error. + return; + } + } + + this.domUtils.showErrorModalDefault(error, 'core.courses.errorselfenrol', true); + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Refresh the data. + * + * @param {any} [refresher] The refresher if this was triggered by a Pull To Refresh. + */ + refreshData(refresher?: any) : Promise { + let promises = []; + + promises.push(this.coursesProvider.invalidateUserCourses()); + promises.push(this.coursesProvider.invalidateCourse(this.course.id)); + promises.push(this.coursesProvider.invalidateCourseEnrolmentMethods(this.course.id)); + // promises.push($mmCoursesDelegate.clearAndInvalidateCoursesOptions(course.id)); + if (this.guestInstanceId) { + promises.push(this.coursesProvider.invalidateCourseGuestEnrolmentInfo(this.guestInstanceId)); + } + + return Promise.all(promises).finally(() => { + return this.getCourse(true); + }).finally(() => { + if (refresher) { + refresher.complete(); + } + }); + } + + /** + * Wait for the user to be enrolled in the course. + * + * @param {boolean} first If it's the first call (true) or it's a recursive call (false). + */ + protected waitForEnrolled(first?: boolean) { + if (first) { + this.waitStart = Date.now(); + } + + // Check if user is enrolled in the course. + return this.coursesProvider.invalidateUserCourses().catch(() => { + // Ignore errors. + }).then(() => { + return this.coursesProvider.getUserCourse(this.course.id); + }).catch(() => { + // Not enrolled, wait a bit and try again. + if (this.pageDestroyed || (Date.now() - this.waitStart > 60000)) { + // Max time reached or the user left the view, stop. + return; + } + + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!this.pageDestroyed) { + // Wait again. + this.waitForEnrolled().then(resolve); + } else { + resolve(); + } + }, 5000); + }); + }); + } +} diff --git a/src/core/courses/pages/my-courses/my-courses.ts b/src/core/courses/pages/my-courses/my-courses.ts index 2b87258e8..54ccbb318 100644 --- a/src/core/courses/pages/my-courses/my-courses.ts +++ b/src/core/courses/pages/my-courses/my-courses.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component } from '@angular/core'; +import { Component, OnDestroy } from '@angular/core'; import { IonicPage, NavController } from 'ionic-angular'; import { CoreEventsProvider } from '../../../../providers/events'; import { CoreSitesProvider } from '../../../../providers/sites'; @@ -27,7 +27,7 @@ import { CoreCoursesProvider } from '../../providers/courses'; selector: 'page-core-courses-my-courses', templateUrl: 'my-courses.html', }) -export class CoreCoursesMyCoursesPage { +export class CoreCoursesMyCoursesPage implements OnDestroy { courses: any[]; filteredCourses: any[]; searchEnabled: boolean; diff --git a/src/core/courses/pages/self-enrol-password/self-enrol-password.html b/src/core/courses/pages/self-enrol-password/self-enrol-password.html new file mode 100644 index 000000000..b9161587b --- /dev/null +++ b/src/core/courses/pages/self-enrol-password/self-enrol-password.html @@ -0,0 +1,23 @@ + + + {{ 'core.courses.selfenrolment' | translate }} + + + + + + + +
+ + + + + + + + +
+
diff --git a/src/core/courses/pages/self-enrol-password/self-enrol-password.module.ts b/src/core/courses/pages/self-enrol-password/self-enrol-password.module.ts new file mode 100644 index 000000000..80a4f5183 --- /dev/null +++ b/src/core/courses/pages/self-enrol-password/self-enrol-password.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreCoursesSelfEnrolPasswordPage } from './self-enrol-password'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; + +@NgModule({ + declarations: [ + CoreCoursesSelfEnrolPasswordPage + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(CoreCoursesSelfEnrolPasswordPage), + TranslateModule.forChild(), + ] +}) +export class CoreCoursesSelfEnrolPasswordPageModule {} diff --git a/src/core/courses/pages/self-enrol-password/self-enrol-password.ts b/src/core/courses/pages/self-enrol-password/self-enrol-password.ts new file mode 100644 index 000000000..dcdc404fe --- /dev/null +++ b/src/core/courses/pages/self-enrol-password/self-enrol-password.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, ViewController } from 'ionic-angular'; + +/** + * Page that displays a form to enter a password to self enrol in a course. + */ +@IonicPage() +@Component({ + selector: 'page-core-courses-self-enrol-password', + templateUrl: 'self-enrol-password.html', +}) +export class CoreCoursesSelfEnrolPasswordPage { + constructor(private viewCtrl: ViewController) {} + + /** + * Close help modal. + */ + close() : void { + this.viewCtrl.dismiss(); + } + + /** + * Submit password. + * + * @param {string} password Password to submit. + */ + submitPassword(password: string) { + this.viewCtrl.dismiss(password); + } +} \ No newline at end of file diff --git a/src/core/courses/providers/courses.ts b/src/core/courses/providers/courses.ts index 655f292b7..d3461af75 100644 --- a/src/core/courses/providers/courses.ts +++ b/src/core/courses/providers/courses.ts @@ -24,6 +24,7 @@ import { CoreSite } from '../../../classes/site'; export class CoreCoursesProvider { public static SEARCH_PER_PAGE = 20; public static ENROL_INVALID_KEY = 'CoreCoursesEnrolInvalidKey'; + public static EVENT_MY_COURSES_UPDATED = 'courses_my_courses_updated'; protected logger; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider) { From cacab75855dfad1f71bd66862069828a561fb991 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 15 Dec 2017 08:03:33 +0100 Subject: [PATCH 12/24] MOBILE-2302 courses: Implement categories and available courses --- .../available-courses/available-courses.html | 16 +++ .../available-courses.module.ts | 33 +++++ .../available-courses/available-courses.ts | 76 +++++++++++ .../courses/pages/categories/categories.html | 39 ++++++ .../pages/categories/categories.module.ts | 35 +++++ .../courses/pages/categories/categories.ts | 122 ++++++++++++++++++ src/directives/format-text.ts | 3 + 7 files changed, 324 insertions(+) create mode 100644 src/core/courses/pages/available-courses/available-courses.html create mode 100644 src/core/courses/pages/available-courses/available-courses.module.ts create mode 100644 src/core/courses/pages/available-courses/available-courses.ts create mode 100644 src/core/courses/pages/categories/categories.html create mode 100644 src/core/courses/pages/categories/categories.module.ts create mode 100644 src/core/courses/pages/categories/categories.ts diff --git a/src/core/courses/pages/available-courses/available-courses.html b/src/core/courses/pages/available-courses/available-courses.html new file mode 100644 index 000000000..91579ff3f --- /dev/null +++ b/src/core/courses/pages/available-courses/available-courses.html @@ -0,0 +1,16 @@ + + + {{ 'core.courses.availablecourses' | translate }} + + + + + + + +
+ +
+ +
+
diff --git a/src/core/courses/pages/available-courses/available-courses.module.ts b/src/core/courses/pages/available-courses/available-courses.module.ts new file mode 100644 index 000000000..5fb00ab0a --- /dev/null +++ b/src/core/courses/pages/available-courses/available-courses.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreCoursesAvailableCoursesPage } from './available-courses'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; + +@NgModule({ + declarations: [ + CoreCoursesAvailableCoursesPage, + ], + imports: [ + CoreComponentsModule, + CoreCoursesComponentsModule, + IonicPageModule.forChild(CoreCoursesAvailableCoursesPage), + TranslateModule.forChild() + ], +}) +export class CoreCoursesAvailableCoursesPageModule {} diff --git a/src/core/courses/pages/available-courses/available-courses.ts b/src/core/courses/pages/available-courses/available-courses.ts new file mode 100644 index 000000000..47d71babf --- /dev/null +++ b/src/core/courses/pages/available-courses/available-courses.ts @@ -0,0 +1,76 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreCoursesProvider } from '../../providers/courses'; + +/** + * Page that displays available courses in current site. + */ +@IonicPage() +@Component({ + selector: 'page-core-courses-available-courses', + templateUrl: 'available-courses.html', +}) +export class CoreCoursesAvailableCoursesPage { + courses: any[] = []; + coursesLoaded: boolean; + + constructor(private coursesProvider: CoreCoursesProvider, private domUtils: CoreDomUtilsProvider, + private sitesProvider: CoreSitesProvider) {} + + /** + * View loaded. + */ + ionViewDidLoad() { + this.loadCourses().finally(() => { + this.coursesLoaded = true; + }); + } + + /** + * Load the courses. + */ + protected loadCourses() { + const frontpageCourseId = this.sitesProvider.getCurrentSite().getSiteHomeId(); + return this.coursesProvider.getCoursesByField().then((courses) => { + this.courses = courses.filter((course) => { + return course.id != frontpageCourseId; + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.courses.errorloadcourses', true); + }); + } + + /** + * Refresh the courses. + * + * @param {any} refresher Refresher. + */ + refreshCourses(refresher: any) { + let promises = []; + + promises.push(this.coursesProvider.invalidateUserCourses()); + promises.push(this.coursesProvider.invalidateCoursesByField()); + + Promise.all(promises).finally(() => { + this.loadCourses().finally(() => { + refresher.complete(); + }); + }); + }; +} diff --git a/src/core/courses/pages/categories/categories.html b/src/core/courses/pages/categories/categories.html new file mode 100644 index 000000000..c22dfe194 --- /dev/null +++ b/src/core/courses/pages/categories/categories.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + +

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

+ {{category.coursecount}} + +
+
+
+ +
+ {{ 'core.courses.courses' | translate }} + +
+ +

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

+
+
+
diff --git a/src/core/courses/pages/categories/categories.module.ts b/src/core/courses/pages/categories/categories.module.ts new file mode 100644 index 000000000..3033dc219 --- /dev/null +++ b/src/core/courses/pages/categories/categories.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreCoursesCategoriesPage } from './categories'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; + +@NgModule({ + declarations: [ + CoreCoursesCategoriesPage, + ], + imports: [ + CoreComponentsModule, + CoreCoursesComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(CoreCoursesCategoriesPage), + TranslateModule.forChild() + ], +}) +export class CoreCoursesCategoriesPageModule {} diff --git a/src/core/courses/pages/categories/categories.ts b/src/core/courses/pages/categories/categories.ts new file mode 100644 index 000000000..2253fc17a --- /dev/null +++ b/src/core/courses/pages/categories/categories.ts @@ -0,0 +1,122 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, NavController, NavParams } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreUtilsProvider } from '../../../../providers/utils/utils'; +import { CoreCoursesProvider } from '../../providers/courses'; + +/** + * Page that displays a list of categories and the courses in the current category if any. + */ +@IonicPage() +@Component({ + selector: 'page-core-courses-categories', + templateUrl: 'categories.html', +}) +export class CoreCoursesCategoriesPage { + title: string; + currentCategory: any; + categories: any[] = []; + courses: any[] = []; + categoriesLoaded: boolean; + + protected categoryId: number; + + constructor(private navCtrl: NavController, navParams: NavParams, private coursesProvider: CoreCoursesProvider, + private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider, translate: TranslateService, + private sitesProvider: CoreSitesProvider) { + this.categoryId = navParams.get('categoryId') || 0; + this.title = translate.instant('core.courses.categories'); + } + + /** + * View loaded. + */ + ionViewDidLoad() { + this.fetchCategories().finally(() => { + this.categoriesLoaded = true; + }); + } + + /** + * Fetch the categories. + */ + protected fetchCategories() { + return this.coursesProvider.getCategories(this.categoryId, true).then((cats) => { + this.currentCategory = undefined; + + cats.forEach((cat, index) => { + if (cat.id == this.categoryId) { + this.currentCategory = cat; + // Delete current Category to avoid problems with the formatTree. + delete cats[index]; + } + }); + + // Sort by depth and sortorder to avoid problems formatting Tree. + cats.sort((a,b) => { + if (a.depth == b.depth) { + return (a.sortorder > b.sortorder) ? 1 : ((b.sortorder > a.sortorder) ? -1 : 0); + } + return a.depth > b.depth ? 1 : -1; + }); + + this.categories = this.utils.formatTree(cats, 'parent', 'id', this.categoryId); + + if (this.currentCategory) { + this.title = this.currentCategory.name; + + return this.coursesProvider.getCoursesByField('category', this.categoryId).then((courses) => { + this.courses = courses; + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.courses.errorloadcourses', true); + }); + } + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.courses.errorloadcategories', true); + }); + } + + /** + * Refresh the categories. + * + * @param {any} refresher Refresher. + */ + refreshCategories(refresher: any) { + let promises = []; + + promises.push(this.coursesProvider.invalidateUserCourses()); + promises.push(this.coursesProvider.invalidateCategories(this.categoryId, true)); + promises.push(this.coursesProvider.invalidateCoursesByField('category', this.categoryId)); + promises.push(this.sitesProvider.getCurrentSite().invalidateConfig()); + + Promise.all(promises).finally(() => { + this.fetchCategories().finally(() => { + refresher.complete(); + }); + }); + } + /** + * Open a category. + * + * @param {number} categoryId The category ID. + */ + openCategory(categoryId: number) { + this.navCtrl.push('CoreCoursesCategoriesPage', {categoryId: categoryId}); + } +} diff --git a/src/directives/format-text.ts b/src/directives/format-text.ts index 96097e12f..2d1dd2c12 100644 --- a/src/directives/format-text.ts +++ b/src/directives/format-text.ts @@ -151,6 +151,7 @@ export class CoreFormatTextDirective implements OnChanges { */ protected formatAndRenderContents() : void { if (!this.text) { + this.element.innerHTML = ''; // Remove current contents. this.finishRender(); return; } @@ -158,6 +159,8 @@ export class CoreFormatTextDirective implements OnChanges { this.text = this.text.trim(); this.formatContents().then((div: HTMLElement) => { + this.element.innerHTML = ''; // Remove current contents. + if (this.maxHeight && div.innerHTML != "") { // Move the children to the current element to be able to calculate the height. // @todo: Display the element? From 5dab527836da049ca855887d1ebc78b851dc819a Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 15 Dec 2017 15:12:01 +0100 Subject: [PATCH 13/24] MOBILE-2302 courses: Implement course overview --- src/assets/img/icons/activities.svg | 178 ++++++++++ src/assets/img/icons/courses.svg | 257 ++++++++++++++ .../courses/components/components.module.ts | 11 +- .../course-progress/course-progress.html | 4 +- .../overview-events/overview-events.html | 66 ++++ .../overview-events/overview-events.scss | 2 + .../overview-events/overview-events.ts | 135 ++++++++ src/core/courses/courses.module.ts | 4 +- src/core/courses/lang/en.json | 16 + .../pages/my-overview/my-overview.html | 74 ++++ .../pages/my-overview/my-overview.module.ts | 33 ++ .../pages/my-overview/my-overview.scss | 3 + .../courses/pages/my-overview/my-overview.ts | 323 ++++++++++++++++++ src/core/courses/providers/handlers.ts | 48 +-- src/core/courses/providers/my-overview.ts | 275 +++++++++++++++ 15 files changed, 1403 insertions(+), 26 deletions(-) create mode 100644 src/assets/img/icons/activities.svg create mode 100644 src/assets/img/icons/courses.svg create mode 100644 src/core/courses/components/overview-events/overview-events.html create mode 100644 src/core/courses/components/overview-events/overview-events.scss create mode 100644 src/core/courses/components/overview-events/overview-events.ts create mode 100644 src/core/courses/pages/my-overview/my-overview.html create mode 100644 src/core/courses/pages/my-overview/my-overview.module.ts create mode 100644 src/core/courses/pages/my-overview/my-overview.scss create mode 100644 src/core/courses/pages/my-overview/my-overview.ts create mode 100644 src/core/courses/providers/my-overview.ts diff --git a/src/assets/img/icons/activities.svg b/src/assets/img/icons/activities.svg new file mode 100644 index 000000000..56243a53c --- /dev/null +++ b/src/assets/img/icons/activities.svg @@ -0,0 +1,178 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/icons/courses.svg b/src/assets/img/icons/courses.svg new file mode 100644 index 000000000..7bd9cb672 --- /dev/null +++ b/src/assets/img/icons/courses.svg @@ -0,0 +1,257 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/core/courses/components/components.module.ts b/src/core/courses/components/components.module.ts index 6cddb3414..d34f17cac 100644 --- a/src/core/courses/components/components.module.ts +++ b/src/core/courses/components/components.module.ts @@ -18,26 +18,31 @@ import { IonicModule } 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 { CoreCoursesCourseProgressComponent } from '../components/course-progress/course-progress'; import { CoreCoursesCourseListItemComponent } from '../components/course-list-item/course-list-item'; +import { CoreCoursesOverviewEventsComponent } from '../components/overview-events/overview-events'; @NgModule({ declarations: [ CoreCoursesCourseProgressComponent, - CoreCoursesCourseListItemComponent + CoreCoursesCourseListItemComponent, + CoreCoursesOverviewEventsComponent ], imports: [ CommonModule, IonicModule, TranslateModule.forChild(), CoreComponentsModule, - CoreDirectivesModule + CoreDirectivesModule, + CorePipesModule ], providers: [ ], exports: [ CoreCoursesCourseProgressComponent, - CoreCoursesCourseListItemComponent + CoreCoursesCourseListItemComponent, + CoreCoursesOverviewEventsComponent ] }) export class CoreCoursesComponentsModule {} diff --git a/src/core/courses/components/course-progress/course-progress.html b/src/core/courses/components/course-progress/course-progress.html index 34c96b8d0..fa01a2fe5 100644 --- a/src/core/courses/components/course-progress/course-progress.html +++ b/src/core/courses/components/course-progress/course-progress.html @@ -7,7 +7,9 @@ {{course.progress}}% - + diff --git a/src/core/courses/components/overview-events/overview-events.html b/src/core/courses/components/overview-events/overview-events.html new file mode 100644 index 000000000..b7240d0f8 --- /dev/null +++ b/src/core/courses/components/overview-events/overview-events.html @@ -0,0 +1,66 @@ + + + + + {{event.action.itemcount}} +

+

{{event.timesort * 1000 | coreFormatDate:"dfmediumdate" }}

+
+
+ + +
{{ 'core.courses.recentlyoverdue' | translate }}
+
    +
  • + +
  • +
+
+ + +
{{ 'core.today' | translate }}
+
    +
  • + +
  • +
+
+ + +
{{ 'core.courses.next7days' | translate }}
+
    +
  • + +
  • +
+
+ + +
{{ 'core.courses.next30days' | translate }}
+
    +
  • + +
  • +
+
+ + +
{{ 'core.courses.future' | translate }}
+
    +
  • + +
  • +
+
+ +
+ + + +
+ + + diff --git a/src/core/courses/components/overview-events/overview-events.scss b/src/core/courses/components/overview-events/overview-events.scss new file mode 100644 index 000000000..e24e0ae82 --- /dev/null +++ b/src/core/courses/components/overview-events/overview-events.scss @@ -0,0 +1,2 @@ +core-courses-course-progress { +} diff --git a/src/core/courses/components/overview-events/overview-events.ts b/src/core/courses/components/overview-events/overview-events.ts new file mode 100644 index 000000000..fd566c4fa --- /dev/null +++ b/src/core/courses/components/overview-events/overview-events.ts @@ -0,0 +1,135 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Input, Output, OnChanges, EventEmitter, SimpleChange } from '@angular/core'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; +import { CoreUtilsProvider } from '../../../../providers/utils/utils'; +import * as moment from 'moment'; + +/** + * Directive to render a list of events in course overview. + */ +@Component({ + selector: 'core-courses-overview-events', + templateUrl: 'overview-events.html' +}) +export class CoreCoursesOverviewEventsComponent implements OnChanges { + @Input() events: any[]; // The events to render. + @Input() showCourse?: boolean|string; // Whether to show the course name. + @Input() canLoadMore?: boolean; // Whether more events can be loaded. + @Output() loadMore: EventEmitter; // Notify that more events should be loaded. + + empty: boolean; + loadingMore: boolean; + recentlyOverdue: any[] = []; + today: any[] = []; + next7Days: any[] = []; + next30Days: any[] = []; + future: any[] = []; + + constructor(private utils: CoreUtilsProvider, private textUtils: CoreTextUtilsProvider, + private domUtils: CoreDomUtilsProvider, private sitesProvider: CoreSitesProvider) { + this.loadMore = new EventEmitter(); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}) { + this.showCourse = this.utils.isTrueOrOne(this.showCourse); + + if (changes.events) { + this.updateEvents(); + } + } + + /** + * Filter the events by time. + * + * @param {number} start Number of days to start getting events from today. E.g. -1 will get events from yesterday. + * @param {number} [end] Number of days after the start. + */ + protected filterEventsByTime(start: number, end?: number) { + start = moment().add(start, 'days').unix(); + end = typeof end != 'undefined' ? moment().add(end, 'days').unix() : end; + + return this.events.filter((event) => { + if (end) { + return start <= event.timesort && event.timesort < end; + } + + return start <= event.timesort; + }).map((event) => { + // @todo: event.iconUrl = this.courseProvider.getModuleIconSrc(event.icon.component); + return event; + }); + } + + /** + * Update the events displayed. + */ + protected updateEvents() { + this.empty = !this.events || this.events.length <= 0; + if (!this.empty) { + this.recentlyOverdue = this.filterEventsByTime(-14, 0); + this.today = this.filterEventsByTime(0, 1); + this.next7Days = this.filterEventsByTime(1, 7); + this.next30Days = this.filterEventsByTime(7, 30); + this.future = this.filterEventsByTime(30); + } + } + + /** + * Load more events clicked. + */ + loadMoreEvents() { + this.loadingMore = true; + this.loadMore.emit(); + // this.loadMore().finally(function() { + // scope.loadingMore = false; + // }); + } + + /** + * Action clicked. + * + * @param {Event} e Click event. + * @param {string} url Url of the action. + */ + action(e: Event, url: string) { + e.preventDefault(); + e.stopPropagation(); + + // Fix URL format. + url = this.textUtils.decodeHTMLEntities(url); + + let modal = this.domUtils.showModalLoading(); + this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(url).finally(() => { + modal.dismiss(); + }); + + // @todo + // $mmContentLinksHelper.handleLink(url).then((treated) => { + // if (!treated) { + // return this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(url); + // } + // }).finally(() => { + // modal.dismiss(); + // }); + + return false; + } +} diff --git a/src/core/courses/courses.module.ts b/src/core/courses/courses.module.ts index 6cc2b1056..48462c473 100644 --- a/src/core/courses/courses.module.ts +++ b/src/core/courses/courses.module.ts @@ -15,6 +15,7 @@ import { NgModule } from '@angular/core'; import { CoreCoursesProvider } from './providers/courses'; import { CoreCoursesMainMenuHandler } from './providers/handlers'; +import { CoreCoursesMyOverviewProvider } from './providers/my-overview'; import { CoreMainMenuDelegate } from '../mainmenu/providers/delegate'; @NgModule({ @@ -23,7 +24,8 @@ import { CoreMainMenuDelegate } from '../mainmenu/providers/delegate'; ], providers: [ CoreCoursesProvider, - CoreCoursesMainMenuHandler + CoreCoursesMainMenuHandler, + CoreCoursesMyOverviewProvider ], exports: [] }) diff --git a/src/core/courses/lang/en.json b/src/core/courses/lang/en.json index f04f3836d..09a14d7f8 100644 --- a/src/core/courses/lang/en.json +++ b/src/core/courses/lang/en.json @@ -4,6 +4,7 @@ "cannotretrievemorecategories": "Categories deeper than level {{$a}} cannot be retrieved.", "categories": "Course categories", "confirmselfenrol": "Are you sure you want to enrol yourself in this course?", + "courseoverview": "Course overview", "courses": "Courses", "downloadcourses": "Download courses", "enrolme": "Enrol me", @@ -13,19 +14,34 @@ "errorselfenrol": "An error occurred while self enrolling.", "filtermycourses": "Filter my courses", "frontpage": "Front page", + "future": "Future", + "inprogress": "In progress", + "morecourses": "More courses", "mycourses": "My courses", + "next30days": "Next 30 days", + "next7days": "Next 7 days", "nocourses": "No course information to show.", + "nocoursesfuture": "No future courses", + "nocoursesinprogress": "No in progress courses", + "nocoursesoverview": "No courses", + "nocoursespast": "No past courses", "nocoursesyet": "No courses in this category", + "noevents": "No upcoming activities due", "nosearchresults": "There were no results from your search", "notenroled": "You are not enrolled in this course", "notenrollable": "You cannot enrol yourself in this course.", "password": "Enrolment key", + "past": "Past", "paymentrequired": "This course requires a payment for entry.", "paypalaccepted": "PayPal payments accepted", + "recentlyoverdue": "Recently overdue", "search": "Search", "searchcourses": "Search courses", "searchcoursesadvice": "You can use the search courses button to find courses to access as a guest or enrol yourself in courses that allow it.", "selfenrolment": "Self enrolment", "sendpaymentbutton": "Send payment via PayPal", + "sortbycourses": "Sort by courses", + "sortbydates": "Sort by dates", + "timeline": "Timeline", "totalcoursesearchresults": "Total courses: {{$a}}" } \ No newline at end of file diff --git a/src/core/courses/pages/my-overview/my-overview.html b/src/core/courses/pages/my-overview/my-overview.html new file mode 100644 index 000000000..1d9e3a1aa --- /dev/null +++ b/src/core/courses/pages/my-overview/my-overview.html @@ -0,0 +1,74 @@ + + + {{ 'core.courses.courseoverview' | translate }} + + + + + + + + + + + + + +
+ + + {{ 'core.courses.sortbydates' | translate }} + {{ 'core.courses.sortbycourses' | translate }} + + + + + + +
+ + + +
+ +
+ + + + + + {{ 'core.courses.inprogress' | translate }} + {{ 'core.courses.future' | translate }} + {{ 'core.courses.past' | translate }} + + + + + + + +
+ + + + + +
+ +
+ +
+ + + + +
+
+
diff --git a/src/core/courses/pages/my-overview/my-overview.module.ts b/src/core/courses/pages/my-overview/my-overview.module.ts new file mode 100644 index 000000000..0259d3bb8 --- /dev/null +++ b/src/core/courses/pages/my-overview/my-overview.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreCoursesMyOverviewPage } from './my-overview'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; + +@NgModule({ + declarations: [ + CoreCoursesMyOverviewPage, + ], + imports: [ + CoreComponentsModule, + CoreCoursesComponentsModule, + IonicPageModule.forChild(CoreCoursesMyOverviewPage), + TranslateModule.forChild() + ], +}) +export class CoreCoursesMyOverviewPageModule {} diff --git a/src/core/courses/pages/my-overview/my-overview.scss b/src/core/courses/pages/my-overview/my-overview.scss new file mode 100644 index 000000000..89812f8f7 --- /dev/null +++ b/src/core/courses/pages/my-overview/my-overview.scss @@ -0,0 +1,3 @@ +page-core-courses-my-courses { + +} diff --git a/src/core/courses/pages/my-overview/my-overview.ts b/src/core/courses/pages/my-overview/my-overview.ts new file mode 100644 index 000000000..a1022b51d --- /dev/null +++ b/src/core/courses/pages/my-overview/my-overview.ts @@ -0,0 +1,323 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, NavController } from 'ionic-angular'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreCoursesProvider } from '../../providers/courses'; +import { CoreCoursesMyOverviewProvider } from '../../providers/my-overview'; +import * as moment from 'moment'; + +/** + * Page that displays My Overview. + */ +@IonicPage() +@Component({ + selector: 'page-core-courses-my-overview', + templateUrl: 'my-overview.html', +}) +export class CoreCoursesMyOverviewPage { + tabShown = 'courses'; + timeline = { + sort: 'sortbydates', + events: [], + loaded: false, + canLoadMore: undefined + }; + timelineCourses = { + courses: [], + loaded: false, + canLoadMore: false + }; + courses = { + selected: 'inprogress', + loaded: false, + filter: '', + past: [], + inprogress: [], + future: [] + }; + showGrid = true; + showFilter = false; + searchEnabled: boolean; + filteredCourses: any[]; + + protected prefetchIconInitialized = false; + protected myCoursesObserver; + protected siteUpdatedObserver; + + constructor(private navCtrl: NavController, private coursesProvider: CoreCoursesProvider, + private domUtils: CoreDomUtilsProvider, private myOverviewProvider: CoreCoursesMyOverviewProvider) {} + + /** + * View loaded. + */ + ionViewDidLoad() { + this.searchEnabled = !this.coursesProvider.isSearchCoursesDisabledInSite(); + + this.switchTab(this.tabShown); + + // @todo: Course download. + } + + /** + * Fetch the timeline. + * + * @param {number} [afterEventId] The last event id. + * @return {Promise} Promise resolved when done. + */ + protected fetchMyOverviewTimeline(afterEventId?: number) : Promise { + return this.myOverviewProvider.getActionEventsByTimesort(afterEventId).then((events) => { + this.timeline.events = events.events; + this.timeline.canLoadMore = events.canLoadMore; + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting my overview data.'); + }); + } + + /** + * Fetch the timeline by courses. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchMyOverviewTimelineByCourses() : Promise { + return this.fetchUserCourses().then((courses) => { + let today = moment().unix(), + courseIds; + courses = courses.filter((course) => { + return course.startdate <= today && (!course.enddate || course.enddate >= today); + }); + + this.timelineCourses.courses = courses; + if (courses.length > 0) { + courseIds = courses.map((course) => { + return course.id; + }); + + return this.myOverviewProvider.getActionEventsByCourses(courseIds).then((courseEvents) => { + this.timelineCourses.courses.forEach((course) => { + course.events = courseEvents[course.id].events; + course.canLoadMore = courseEvents[course.id].canLoadMore; + }); + }); + } + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting my overview data.'); + }); + } + + /** + * Fetch the courses for my overview. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchMyOverviewCourses() : Promise { + return this.fetchUserCourses().then((courses) => { + const today = moment().unix(); + + this.courses.past = []; + this.courses.inprogress = []; + this.courses.future = []; + + courses.forEach((course) => { + if (course.startdate > today) { + // Courses that have not started yet. + this.courses.future.push(course); + } else if (course.enddate && course.enddate < today) { + // Courses that have already ended. + this.courses.past.push(course); + } else { + // Courses still in progress. + this.courses.inprogress.push(course); + } + }); + + this.courses.filter = ''; + this.showFilter = false; + this.filteredCourses = this.courses[this.courses.selected]; + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting my overview data.'); + }); + } + + /** + * Fetch user courses. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchUserCourses() : Promise { + let courseIds; + return this.coursesProvider.getUserCourses().then((courses) => { + courseIds = courses.map((course) => { + return course.id; + }); + + // Load course options of the course. + return this.coursesProvider.getCoursesOptions(courseIds).then((options) => { + courses.forEach((course) => { + course.showProgress = true; + course.progress = isNaN(parseInt(course.progress, 10)) ? false : parseInt(course.progress, 10); + + course.navOptions = options.navOptions[course.id]; + course.admOptions = options.admOptions[course.id]; + }); + + return courses.sort((a, b) => { + const compareA = a.fullname.toLowerCase(), + compareB = b.fullname.toLowerCase(); + + return compareA.localeCompare(compareB); + }); + }); + }); + } + + /** + * Show or hide the filter. + */ + switchFilter() { + this.showFilter = !this.showFilter; + this.courses.filter = ''; + this.filteredCourses = this.courses[this.courses.selected]; + } + + /** + * The filter has changed. + * + * @param {string} newValue New filter value. + */ + filterChanged(newValue: string) { + if (!newValue || !this.courses[this.courses.selected]) { + this.filteredCourses = this.courses[this.courses.selected]; + } else { + this.filteredCourses = this.courses[this.courses.selected].filter((course) => { + return course.fullname.indexOf(newValue) > -1; + }); + } + } + + /** + * Switch grid/list view. + */ + switchGrid() { + this.showGrid = !this.showGrid; + } + + /** + * Refresh the data. + * + * @param {any} refresher Refresher. + */ + refreshMyOverview(refresher: any) { + let promises = []; + + if (this.tabShown == 'timeline') { + promises.push(this.myOverviewProvider.invalidateActionEventsByTimesort()); + promises.push(this.myOverviewProvider.invalidateActionEventsByCourses()); + } + + promises.push(this.coursesProvider.invalidateUserCourses()); + // promises.push(this.coursesDelegate.clearAndInvalidateCoursesOptions()); + + return Promise.all(promises).finally(() => { + switch (this.tabShown) { + case 'timeline': + switch (this.timeline.sort) { + case 'sortbydates': + return this.fetchMyOverviewTimeline(); + case 'sortbycourses': + return this.fetchMyOverviewTimelineByCourses(); + } + break; + case 'courses': + return this.fetchMyOverviewCourses(); + } + }).finally(() => { + refresher.complete(); + }); + } + + /** + * Change timeline sort being viewed. + */ + switchSort() { + switch (this.timeline.sort) { + case 'sortbydates': + if (!this.timeline.loaded) { + this.fetchMyOverviewTimeline().finally(() => { + this.timeline.loaded = true; + }); + } + break; + case 'sortbycourses': + if (!this.timelineCourses.loaded) { + this.fetchMyOverviewTimelineByCourses().finally(() => { + this.timelineCourses.loaded = true; + }); + } + break; + } + } + + /** + * Change tab being viewed. + * + * @param {string} tab Tab to display. + */ + switchTab(tab: string) { + this.tabShown = tab; + switch (this.tabShown) { + case 'timeline': + if (!this.timeline.loaded) { + this.fetchMyOverviewTimeline().finally(() => { + this.timeline.loaded = true; + }); + } + break; + case 'courses': + if (!this.courses.loaded) { + this.fetchMyOverviewCourses().finally(() => { + this.courses.loaded = true; + }); + } + break; + } + } + + /** + * Load more events. + */ + loadMoreTimeline() : Promise { + return this.fetchMyOverviewTimeline(this.timeline.canLoadMore); + } + + /** + * Load more events. + * + * @param {any} course Course. + */ + loadMoreCourse(course) { + return this.myOverviewProvider.getActionEventsByCourse(course.id, course.canLoadMore).then((courseEvents) => { + course.events = course.events.concat(courseEvents.events); + course.canLoadMore = courseEvents.canLoadMore; + }); + } + + /** + * Go to search courses. + */ + openSearch() { + this.navCtrl.push('CoreCoursesSearchPage'); + } +} diff --git a/src/core/courses/providers/handlers.ts b/src/core/courses/providers/handlers.ts index cece22a00..c003917b3 100644 --- a/src/core/courses/providers/handlers.ts +++ b/src/core/courses/providers/handlers.ts @@ -15,6 +15,7 @@ import { Injectable } from '@angular/core'; import { CoreCoursesProvider } from './courses'; import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '../../mainmenu/providers/delegate'; +import { CoreCoursesMyOverviewProvider } from '../providers/my-overview'; /** * Handler to inject an option into main menu. @@ -23,8 +24,9 @@ import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '../../mainmenu/pro export class CoreCoursesMainMenuHandler implements CoreMainMenuHandler { name = 'mmCourses'; priority = 1100; + isOverviewEnabled: boolean; - constructor(private coursesProvider: CoreCoursesProvider) {} + constructor(private coursesProvider: CoreCoursesProvider, private myOverviewProvider: CoreCoursesMyOverviewProvider) {} /** * Check if the handler is enabled on a site level. @@ -32,21 +34,16 @@ export class CoreCoursesMainMenuHandler implements CoreMainMenuHandler { * @return {boolean} Whether or not the handler is enabled on a site level. */ isEnabled(): boolean|Promise { - let myCoursesDisabled = this.coursesProvider.isMyCoursesDisabledInSite(); + // Check if my overview is enabled. + return this.myOverviewProvider.isEnabled().then((enabled) => { + this.isOverviewEnabled = enabled; + if (enabled) { + return true; + } - // Check if overview side menu is available, so it won't show My courses. - // var $mmaMyOverview = $mmAddonManager.get('$mmaMyOverview'); - // if ($mmaMyOverview) { - // return $mmaMyOverview.isSideMenuAvailable().then(function(enabled) { - // if (enabled) { - // return false; - // } - // // Addon not enabled, check my courses. - // return !myCoursesDisabled; - // }); - // } - // Addon not present, check my courses. - return !myCoursesDisabled; + // My overview not enabled, check if my courses is enabled. + return !this.coursesProvider.isMyCoursesDisabledInSite(); + }); } /** @@ -55,11 +52,20 @@ export class CoreCoursesMainMenuHandler implements CoreMainMenuHandler { * @return {CoreMainMenuHandlerData} Data needed to render the handler. */ getDisplayData(): CoreMainMenuHandlerData { - return { - icon: 'ionic', - title: 'core.courses.mycourses', - page: 'CoreCoursesMyCoursesPage', - class: 'mm-mycourses-handler' - }; + if (this.isOverviewEnabled) { + return { + icon: 'ionic', + title: 'core.courses.courseoverview', + page: 'CoreCoursesMyOverviewPage', + class: 'mm-courseoverview-handler' + }; + } else { + return { + icon: 'ionic', + title: 'core.courses.mycourses', + page: 'CoreCoursesMyCoursesPage', + class: 'mm-mycourses-handler' + }; + } } } diff --git a/src/core/courses/providers/my-overview.ts b/src/core/courses/providers/my-overview.ts new file mode 100644 index 000000000..aab67f913 --- /dev/null +++ b/src/core/courses/providers/my-overview.ts @@ -0,0 +1,275 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Injectable } from '@angular/core'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreSite } from '../../../classes/site'; +import * as moment from 'moment'; + +/** + * Service that provides some features regarding course overview. + */ +@Injectable() +export class CoreCoursesMyOverviewProvider { + public static EVENTS_LIMIT = 20; + public static EVENTS_LIMIT_PER_COURSE = 10; + + constructor(private sitesProvider: CoreSitesProvider) {} + + /** + * Get calendar action events for the given course. + * + * @param {number} courseId Only events in this course. + * @param {number} [afterEventId] The last seen event id. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise<{events: any[], canLoadMore: number}>} Promise resolved when the info is retrieved. + */ + getActionEventsByCourse(courseId: number, afterEventId?: number, siteId?: string) : + Promise<{events: any[], canLoadMore: number}> { + + return this.sitesProvider.getSite(siteId).then((site) => { + let time = moment().subtract(14, 'days').unix(), // Check two weeks ago. + data: any = { + timesortfrom: time, + courseid: courseId, + limitnum: CoreCoursesMyOverviewProvider.EVENTS_LIMIT_PER_COURSE + }, + preSets = { + cacheKey: this.getActionEventsByCourseCacheKey(courseId) + }; + + if (afterEventId) { + data.aftereventid = afterEventId; + } + + return site.read('core_calendar_get_action_events_by_course', data, preSets).then((courseEvents) : any => { + if (courseEvents && courseEvents.events) { + return this.treatCourseEvents(courseEvents, time); + } + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for get calendar action events for the given course value WS call. + * + * @param {number} courseId Only events in this course. + * @return {string} Cache key. + */ + protected getActionEventsByCourseCacheKey(courseId: number) : string { + return this.getActionEventsByCoursesCacheKey() + ':' + courseId; + } + + /** + * Get calendar action events for a given list of courses. + * + * @param {number[]} courseIds Course IDs. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise<{[s: string]: {events: any[], canLoadMore: number}}>} Promise resolved when the info is retrieved. + */ + getActionEventsByCourses(courseIds: number[], siteId?: string) : Promise<{[s: string]: {events: any[], canLoadMore: number}}> { + return this.sitesProvider.getSite(siteId).then((site) => { + let time = moment().subtract(14, 'days').unix(), // Check two weeks ago. + data = { + timesortfrom: time, + courseids: courseIds, + limitnum: CoreCoursesMyOverviewProvider.EVENTS_LIMIT_PER_COURSE + }, + preSets = { + cacheKey: this.getActionEventsByCoursesCacheKey() + }; + + return site.read('core_calendar_get_action_events_by_courses', data, preSets).then((events) : any => { + if (events && events.groupedbycourse) { + let courseEvents = {}; + + events.groupedbycourse.forEach((course) => { + courseEvents[course.courseid] = this.treatCourseEvents(course, time); + }); + + return courseEvents; + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for get calendar action events for a given list of courses value WS call. + * + * @return {string} Cache key. + */ + protected getActionEventsByCoursesCacheKey() : string { + return this.getRootCacheKey() + 'bycourse'; + } + + /** + * Get calendar action events based on the timesort value. + * + * @param {number} [afterEventId] The last seen event id. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise<{events: any[], canLoadMore: number}>} Promise resolved when the info is retrieved. + */ + getActionEventsByTimesort(afterEventId: number, siteId?: string) : Promise<{events: any[], canLoadMore: number}> { + return this.sitesProvider.getSite(siteId).then((site) => { + let time = moment().subtract(14, 'days').unix(), // Check two weeks ago. + data: any = { + timesortfrom: time, + limitnum: CoreCoursesMyOverviewProvider.EVENTS_LIMIT + }, + preSets = { + cacheKey: this.getActionEventsByTimesortCacheKey(afterEventId, data.limitnum), + getCacheUsingCacheKey: true, + uniqueCacheKey: true + }; + + if (afterEventId) { + data.aftereventid = afterEventId; + } + + return site.read('core_calendar_get_action_events_by_timesort', data, preSets).then((events) : any => { + if (events && events.events) { + let canLoadMore = events.events.length >= data.limitnum ? events.lastid : undefined; + + // Filter events by time in case it uses cache. + events = events.events.filter((element) => { + return element.timesort >= time; + }); + + return { + events: events, + canLoadMore: canLoadMore + }; + } + return Promise.reject(null); + }); + }); + } + + /** + * Get prefix cache key for calendar action events based on the timesort value WS calls. + * + * @return {string} Cache key. + */ + protected getActionEventsByTimesortPrefixCacheKey() : string { + return this.getRootCacheKey() + 'bytimesort:'; + } + + /** + * Get cache key for get calendar action events based on the timesort value WS call. + * + * @param {number} [afterEventId] The last seen event id. + * @param {number} [limit] Limit num of the call. + * @return {string} Cache key. + */ + protected getActionEventsByTimesortCacheKey(afterEventId?: number, limit?: number) : string { + afterEventId = afterEventId || 0; + limit = limit || 0; + return this.getActionEventsByTimesortPrefixCacheKey() + afterEventId + ':' + limit; + } + + /** + * Get the root cache key for the WS calls related to overview. + * + * @return {string} Root cache key. + */ + protected getRootCacheKey() : string { + return 'myoverview:'; + } + + /** + * Invalidates get calendar action events for a given list of courses WS call. + * + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateActionEventsByCourses(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getActionEventsByCoursesCacheKey()); + }); + } + + /** + * Invalidates get calendar action events based on the timesort value WS call. + * + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateActionEventsByTimesort(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getActionEventsByTimesortPrefixCacheKey()); + }); + } + + /** + * Returns whether or not My Overview is available for a certain site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if available, resolved with false or rejected otherwise. + */ + isAvailable(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.wsAvailable('core_calendar_get_action_events_by_courses'); + }); + } + + /** + * Check if My Overview is disabled in a certain site. + * + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} Whether it's disabled. + */ + isDisabledInSite(site?: CoreSite) : boolean { + site = site || this.sitesProvider.getCurrentSite(); + return site.isFeatureDisabled('$mmSideMenuDelegate_mmaMyOverview'); + } + + /** + * Check if My Overview is available and not disabled. + * + * @return {Promise} Promise resolved with true if enabled, resolved with false otherwise. + */ + isEnabled() : Promise { + if (!this.isDisabledInSite()) { + return this.isAvailable().catch(() => { + return false; + }); + } + return Promise.resolve(false); + } + + /** + * Handles course events, filtering and treating if more can be loaded. + * + * @param {any} course Object containing response course events info. + * @param {number} timeFrom Current time to filter events from. + * @return {{events: any[], canLoadMore: number}} Object with course events and last loaded event id if more can be loaded. + */ + protected treatCourseEvents(course: any, timeFrom: number) : {events: any[], canLoadMore: number} { + let canLoadMore : number = + course.events.length >= CoreCoursesMyOverviewProvider.EVENTS_LIMIT_PER_COURSE ? course.lastid : undefined; + + // Filter events by time in case it uses cache. + course.events = course.events.filter((element) => { + return element.timesort >= timeFrom; + }); + + return { + events: course.events, + canLoadMore: canLoadMore + }; + } +} From 86a9d0dceac41bf3f4cd222d9ff33bbf2243f67b Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 18 Dec 2017 10:22:04 +0100 Subject: [PATCH 14/24] MOBILE-2302 core: Pass undefined instead of null for missing params TypeScript does not use the default value if it receives a null --- src/classes/site.ts | 2 +- src/classes/sqlitedb.ts | 6 +++--- src/core/emulator/providers/local-notifications.ts | 6 +++--- src/core/login/pages/email-signup/email-signup.ts | 2 +- src/core/login/providers/helper.ts | 2 +- src/providers/sites.ts | 2 +- src/providers/utils/dom.ts | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/classes/site.ts b/src/classes/site.ts index 2b46c8e86..f24809ec9 100644 --- a/src/classes/site.ts +++ b/src/classes/site.ts @@ -1061,7 +1061,7 @@ export class CoreSite { } if (alertMessage) { - let alert = this.domUtils.showAlert('core.notice', alertMessage, null, 3000); + let alert = this.domUtils.showAlert('core.notice', alertMessage, undefined, 3000); alert.onDidDismiss(() => { if (inApp) { resolve(this.utils.openInApp(url, options)); diff --git a/src/classes/sqlitedb.ts b/src/classes/sqlitedb.ts index 6b718eca8..04f9fa6f6 100644 --- a/src/classes/sqlitedb.ts +++ b/src/classes/sqlitedb.ts @@ -262,7 +262,7 @@ export class SQLiteDB { * @return {Promise} Promise resolved when done. */ deleteRecords(table: string, conditions?: object) : Promise { - if (conditions === null) { + if (conditions === null || typeof conditions == 'undefined') { // No conditions, delete the whole table. return this.execute(`DELETE FROM TABLE ${table}`); } @@ -674,10 +674,10 @@ export class SQLiteDB { */ normaliseLimitFromNum(limitFrom: any, limitNum: any) : number[] { // We explicilty treat these cases as 0. - if (limitFrom === null || limitFrom === '' || limitFrom === -1) { + if (typeof limitFrom == 'undefined' || limitFrom === null || limitFrom === '' || limitFrom === -1) { limitFrom = 0; } - if (limitNum === null || limitNum === '' || limitNum === -1) { + if (typeof limitNum == 'undefined' || limitNum === null || limitNum === '' || limitNum === -1) { limitNum = 0; } diff --git a/src/core/emulator/providers/local-notifications.ts b/src/core/emulator/providers/local-notifications.ts index 35c1b6561..d7eef13db 100644 --- a/src/core/emulator/providers/local-notifications.ts +++ b/src/core/emulator/providers/local-notifications.ts @@ -261,7 +261,7 @@ export class LocalNotificationsMock extends LocalNotifications { * @returns {Promise>} */ getAll(): Promise> { - return Promise.resolve(this.getNotifications(null, true, true)); + return Promise.resolve(this.getNotifications(undefined, true, true)); } /** @@ -292,7 +292,7 @@ export class LocalNotificationsMock extends LocalNotifications { * @returns {Promise>} */ getAllScheduled(): Promise> { - return Promise.resolve(this.getNotifications(null, true, false)); + return Promise.resolve(this.getNotifications(undefined, true, false)); } /** @@ -301,7 +301,7 @@ export class LocalNotificationsMock extends LocalNotifications { * @returns {Promise>} */ getAllTriggered(): Promise> { - return Promise.resolve(this.getNotifications(null, false, true)); + return Promise.resolve(this.getNotifications(undefined, false, true)); } /** diff --git a/src/core/login/pages/email-signup/email-signup.ts b/src/core/login/pages/email-signup/email-signup.ts index cd87027c3..dcf5a0f2e 100644 --- a/src/core/login/pages/email-signup/email-signup.ts +++ b/src/core/login/pages/email-signup/email-signup.ts @@ -71,7 +71,7 @@ export class CoreLoginEmailSignupPage { this.usernameErrors = this.loginHelper.getErrorMessages('core.login.usernamerequired'); this.passwordErrors = this.loginHelper.getErrorMessages('core.login.passwordrequired'); this.emailErrors = this.loginHelper.getErrorMessages('core.login.missingemail'); - this.email2Errors = this.loginHelper.getErrorMessages('core.login.missingemail', null, 'core.login.emailnotmatch'); + this.email2Errors = this.loginHelper.getErrorMessages('core.login.missingemail', undefined, 'core.login.emailnotmatch'); this.policyErrors = this.loginHelper.getErrorMessages('core.login.policyagree'); } diff --git a/src/core/login/providers/helper.ts b/src/core/login/providers/helper.ts index d6baa15d7..8b6314010 100644 --- a/src/core/login/providers/helper.ts +++ b/src/core/login/providers/helper.ts @@ -690,7 +690,7 @@ export class CoreLoginHelperProvider { * @param {string} error Error message. */ openChangePassword(siteUrl: string, error: string) : void { - let alert = this.domUtils.showAlert(this.translate.instant('core.notice'), error, null, 3000); + let alert = this.domUtils.showAlert(this.translate.instant('core.notice'), error, undefined, 3000); alert.onDidDismiss(() => { this.utils.openInApp(siteUrl + '/login/change_password.php'); }); diff --git a/src/providers/sites.ts b/src/providers/sites.ts index ff9877eec..00311e8bc 100644 --- a/src/providers/sites.ts +++ b/src/providers/sites.ts @@ -1043,7 +1043,7 @@ export class CoreSitesProvider { return Promise.resolve(); } - return site.getConfig(null, true); + return site.getConfig(undefined, true); } /** diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index 59d48c641..719e68a5c 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -712,7 +712,7 @@ export class CoreDomUtilsProvider { } let message = this.textUtils.decodeHTML(needsTranslate ? this.translate.instant(error) : error); - return this.showAlert(this.getErrorTitle(message), message, null, autocloseTime); + return this.showAlert(this.getErrorTitle(message), message, undefined, autocloseTime); } /** From 2b03bcf3e4e988b8d945a1b35672d7b47aa90d93 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 18 Dec 2017 13:06:12 +0100 Subject: [PATCH 15/24] MOBILE-2302 core: Implement core-file component --- src/app/app.scss | 9 + src/assets/img/files/archive-64.png | Bin 0 -> 3914 bytes src/assets/img/files/audio-64.png | Bin 0 -> 4609 bytes src/assets/img/files/avi-64.png | Bin 0 -> 4698 bytes src/assets/img/files/base-64.png | Bin 0 -> 4069 bytes src/assets/img/files/bmp-64.png | Bin 0 -> 4270 bytes src/assets/img/files/calc-64.png | Bin 0 -> 3438 bytes src/assets/img/files/chart-64.png | Bin 0 -> 3359 bytes src/assets/img/files/database-64.png | Bin 0 -> 4949 bytes src/assets/img/files/document-64.png | Bin 0 -> 4876 bytes src/assets/img/files/draw-64.png | Bin 0 -> 3441 bytes src/assets/img/files/eps-64.png | Bin 0 -> 3679 bytes src/assets/img/files/epub-64.png | Bin 0 -> 3839 bytes src/assets/img/files/flash-64.png | Bin 0 -> 3752 bytes src/assets/img/files/folder-64.png | Bin 0 -> 1102 bytes src/assets/img/files/folder-open-64.png | Bin 0 -> 1255 bytes src/assets/img/files/gif-64.png | Bin 0 -> 4540 bytes src/assets/img/files/html-64.png | Bin 0 -> 2150 bytes src/assets/img/files/image-64.png | Bin 0 -> 5800 bytes src/assets/img/files/impress-64.png | Bin 0 -> 3321 bytes src/assets/img/files/isf-64.png | Bin 0 -> 3509 bytes src/assets/img/files/jpeg-64.png | Bin 0 -> 4459 bytes src/assets/img/files/markup-64.png | Bin 0 -> 4171 bytes src/assets/img/files/math-64.png | Bin 0 -> 3170 bytes src/assets/img/files/moodle-64.png | Bin 0 -> 3578 bytes src/assets/img/files/mp3-64.png | Bin 0 -> 4301 bytes src/assets/img/files/mpeg-64.png | Bin 0 -> 5308 bytes src/assets/img/files/oth-64.png | Bin 0 -> 3724 bytes src/assets/img/files/pdf-64.png | Bin 0 -> 4231 bytes src/assets/img/files/png-64.png | Bin 0 -> 4528 bytes src/assets/img/files/powerpoint-64.png | Bin 0 -> 4634 bytes src/assets/img/files/psd-64.png | Bin 0 -> 3944 bytes src/assets/img/files/publisher-64.png | Bin 0 -> 4973 bytes src/assets/img/files/quicktime-64.png | Bin 0 -> 4664 bytes src/assets/img/files/sourcecode-64.png | Bin 0 -> 4297 bytes src/assets/img/files/spreadsheet-64.png | Bin 0 -> 5317 bytes src/assets/img/files/text-64.png | Bin 0 -> 3692 bytes src/assets/img/files/tiff-64.png | Bin 0 -> 4684 bytes src/assets/img/files/unknown-64.png | Bin 0 -> 2150 bytes src/assets/img/files/video-64.png | Bin 0 -> 4211 bytes src/assets/img/files/wav-64.png | Bin 0 -> 5410 bytes src/assets/img/files/wmv-64.png | Bin 0 -> 4211 bytes src/assets/img/files/writer-64.png | Bin 0 -> 2836 bytes src/classes/sqlitedb.ts | 19 ++ src/components/components.module.ts | 7 +- src/components/file/file.html | 13 + src/components/file/file.scss | 28 ++ src/components/file/file.ts | 291 ++++++++++++++++++ .../pages/course-preview/course-preview.html | 2 +- src/core/emulator/providers/file.ts | 1 - src/providers/file.ts | 2 +- src/providers/filepool.ts | 68 ++-- src/providers/utils/mimetype.ts | 4 +- src/theme/variables.scss | 7 + 54 files changed, 418 insertions(+), 33 deletions(-) create mode 100644 src/assets/img/files/archive-64.png create mode 100644 src/assets/img/files/audio-64.png create mode 100644 src/assets/img/files/avi-64.png create mode 100644 src/assets/img/files/base-64.png create mode 100644 src/assets/img/files/bmp-64.png create mode 100644 src/assets/img/files/calc-64.png create mode 100644 src/assets/img/files/chart-64.png create mode 100644 src/assets/img/files/database-64.png create mode 100644 src/assets/img/files/document-64.png create mode 100644 src/assets/img/files/draw-64.png create mode 100644 src/assets/img/files/eps-64.png create mode 100644 src/assets/img/files/epub-64.png create mode 100644 src/assets/img/files/flash-64.png create mode 100644 src/assets/img/files/folder-64.png create mode 100644 src/assets/img/files/folder-open-64.png create mode 100644 src/assets/img/files/gif-64.png create mode 100644 src/assets/img/files/html-64.png create mode 100644 src/assets/img/files/image-64.png create mode 100644 src/assets/img/files/impress-64.png create mode 100644 src/assets/img/files/isf-64.png create mode 100644 src/assets/img/files/jpeg-64.png create mode 100644 src/assets/img/files/markup-64.png create mode 100644 src/assets/img/files/math-64.png create mode 100644 src/assets/img/files/moodle-64.png create mode 100644 src/assets/img/files/mp3-64.png create mode 100644 src/assets/img/files/mpeg-64.png create mode 100644 src/assets/img/files/oth-64.png create mode 100644 src/assets/img/files/pdf-64.png create mode 100644 src/assets/img/files/png-64.png create mode 100644 src/assets/img/files/powerpoint-64.png create mode 100644 src/assets/img/files/psd-64.png create mode 100644 src/assets/img/files/publisher-64.png create mode 100644 src/assets/img/files/quicktime-64.png create mode 100644 src/assets/img/files/sourcecode-64.png create mode 100644 src/assets/img/files/spreadsheet-64.png create mode 100644 src/assets/img/files/text-64.png create mode 100644 src/assets/img/files/tiff-64.png create mode 100644 src/assets/img/files/unknown-64.png create mode 100644 src/assets/img/files/video-64.png create mode 100644 src/assets/img/files/wav-64.png create mode 100644 src/assets/img/files/wmv-64.png create mode 100644 src/assets/img/files/writer-64.png create mode 100644 src/components/file/file.html create mode 100644 src/components/file/file.scss create mode 100644 src/components/file/file.ts diff --git a/src/app/app.scss b/src/app/app.scss index 56f3c6d57..ed0a5508b 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -216,3 +216,12 @@ core-format-text, *[core-format-text] { display: inline; } } + +// Media item, ideal for icons. +.item-media { + min-height: $item-media-height + ($content-padding * 2); + > img:first-child { + max-width: $item-media-width; + max-height: $item-media-height; + } +} diff --git a/src/assets/img/files/archive-64.png b/src/assets/img/files/archive-64.png new file mode 100644 index 0000000000000000000000000000000000000000..ba7111a024e281a4b889c45b587d5021832cc2d7 GIT binary patch literal 3914 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 literal 0 HcmV?d00001 diff --git a/src/assets/img/files/avi-64.png b/src/assets/img/files/avi-64.png new file mode 100644 index 0000000000000000000000000000000000000000..ec1a942cd13bfa3441aa2d3914f05442a3c862a0 GIT binary patch literal 4698 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 literal 0 HcmV?d00001 diff --git a/src/assets/img/files/database-64.png b/src/assets/img/files/database-64.png new file mode 100644 index 0000000000000000000000000000000000000000..33d5043c19bd08fc8d9baf32d068c844aad05de0 GIT binary patch literal 4949 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 TXzIVq00000NkvXXu0mjf?gV21 literal 0 HcmV?d00001 diff --git a/src/assets/img/files/document-64.png b/src/assets/img/files/document-64.png new file mode 100644 index 0000000000000000000000000000000000000000..0888ebbbd8c668aa57328aa9ff7d347186f45f80 GIT binary patch literal 4876 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 T5xp!m00000NkvXXu0mjf)^?Gt literal 0 HcmV?d00001 diff --git a/src/assets/img/files/eps-64.png b/src/assets/img/files/eps-64.png new file mode 100644 index 0000000000000000000000000000000000000000..c42492441422918841a03f87313b53a5e27382fb GIT binary patch literal 3679 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 UYkc;Fvj6}907*qoM6N<$g21u*+yDRo literal 0 HcmV?d00001 diff --git a/src/assets/img/files/folder-open-64.png b/src/assets/img/files/folder-open-64.png new file mode 100644 index 0000000000000000000000000000000000000000..27f7271bd09b40b0a4fc158818b820393d9ee75f GIT binary patch literal 1255 zcmVqyCth=h=Uy}cAN(Npi0AF*XpCm(ROY_| zt`x(~w-4`sKZOH`jNv}NAK=cIjUWCmX9~fLD@6Uw_r2}n2)N6!0SQSQ#Gg+W{&E&9 z1g;$QGs8s^kkfGUPN9u(92~%LK!|z+%fA?o*T+zkFKcH>l`I6JR`vFZBx-?4v?w_Z z02f8TW(h+M?1R?wl_!*N@dONm4H>5=0=jqtQqmBL(-USFQ2?ftO!P!X>7ogk1Pzkt z33m+_v?27mFW&SO;9)cffx|~8!$awa>232{a%Pt{H-F+ly~LbRx}t_xr`%zrVNCE6f&dPB6Rvr;iCzxvqI{th}x8|4E1#C`&F^1rh- zw*w|a_=P)9VvQ7|f*IXVKl4Um9VC7M&=jzdQd7W?qRaw4F(k=d7bqZvUludgi9u!| z&=aw^`H)!0O8Mn^1st9nx@GQ}b%LqGzkp=stTQOwa^7Qt0=@Wizr6-*V@<$aCEbIL zaQE$8{4cCfWDrw15=z)a%p>Cx%`H z7%?UQP|4HL0%Ei&2!Ofh_ZJ{yvZaj+5D+v~6<{KTeg&iy0Kj3qfDn8s0Flh-6OqDF zfR7iD6sFkH+-5Mk#9=dr+LSO}z#yNN81O=H%EU~a3h+rV3o^3pO^hq}u~`0FD7ifTJ}Z1_r=@0I1eh>zbEA zqi$v2C$H2s3d_8#bFJsVE-(eo)qEECr@NmVD?mU5LJfmuoq&xRL(R*+Mp`~+HMatw z(2l&8_kekg*)mr9Js__=Hv{R?x$V RF~0x+002ovPDHLkV1jS^KRy5e literal 0 HcmV?d00001 diff --git a/src/assets/img/files/gif-64.png b/src/assets/img/files/gif-64.png new file mode 100644 index 0000000000000000000000000000000000000000..2373292b7a5315048e15038a4ee440f6fee833b6 GIT binary patch literal 4540 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 literal 0 HcmV?d00001 diff --git a/src/assets/img/files/image-64.png b/src/assets/img/files/image-64.png new file mode 100644 index 0000000000000000000000000000000000000000..2d8f9e4fadb98d0e982887883c89503ed8f9df08 GIT binary patch literal 5800 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

CbvkA#sjswY+WNg5gV4sWVGqj{>Nw>LQ>g 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 DU?hZV literal 0 HcmV?d00001 diff --git a/src/assets/img/files/isf-64.png b/src/assets/img/files/isf-64.png new file mode 100644 index 0000000000000000000000000000000000000000..ad5a1886787a768fda32c34c4322bc524eec9007 GIT binary patch literal 3509 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 literal 0 HcmV?d00001 diff --git a/src/assets/img/files/moodle-64.png b/src/assets/img/files/moodle-64.png new file mode 100644 index 0000000000000000000000000000000000000000..44ad3a37d35797cb18baad5437af81077663efeb GIT binary patch literal 3578 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 literal 0 HcmV?d00001 diff --git a/src/assets/img/files/mpeg-64.png b/src/assets/img/files/mpeg-64.png new file mode 100644 index 0000000000000000000000000000000000000000..05d77fa7bd6ed5d2a249629e634b89cf99892bc7 GIT binary patch literal 5308 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 O0000_9L_so{#QF2*kk1#|f9%J8>{~BizI-K{ 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 literal 0 HcmV?d00001 diff --git a/src/assets/img/files/spreadsheet-64.png b/src/assets/img/files/spreadsheet-64.png new file mode 100644 index 0000000000000000000000000000000000000000..00427c0e7807817bf9a1901361ab6313ac5ff119 GIT binary patch literal 5317 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 XMC)c5GEFf!00000NkvXXu0mjfP=s4o literal 0 HcmV?d00001 diff --git a/src/assets/img/files/text-64.png b/src/assets/img/files/text-64.png new file mode 100644 index 0000000000000000000000000000000000000000..7b397cea5abac6b8885b260057958e84e714307f GIT binary patch literal 3692 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 KMNUMnLSTXt^*Fi! literal 0 HcmV?d00001 diff --git a/src/assets/img/files/tiff-64.png b/src/assets/img/files/tiff-64.png new file mode 100644 index 0000000000000000000000000000000000000000..c11a85e28b7daad21656e3bfc25707816db5d998 GIT binary patch literal 4684 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 O0000mASl literal 0 HcmV?d00001 diff --git a/src/assets/img/files/unknown-64.png b/src/assets/img/files/unknown-64.png new file mode 100644 index 0000000000000000000000000000000000000000..7f703bb834a1ce3c26bfd722fbe515c9d8e562f1 GIT binary patch literal 2150 zcmV-s2$}bZP)f$UW?h;+2T9i@s1Zh>|qbPui0#R zl}ZIPo-_mAH{bvM_rLS{*T4QJPk;K;?KOk{ju`&rE|gNxS|Ldi z9boO-gCN{&Tg5*|qP0f5-NuJL^dVm<-1xu;KJdx(o$L%MH3$NfN+og_mX|xYcx()# zBO~=&Z@u*^wc5av9it@#i823~0}O~?nPS2HrPk`FnH^xkU)^pme?(qmk9wYmbLY<3 z**uAQy$%uvCFQ>F`!p7-R;|Lq!UC$*8n$fNhD$EF6po|O>2$ggBBsqSJP}Z%41bTI z5~09mQ{+AFJ6dCCsQT(c!Tnek2UINlF33BGJa39W|J&mQMC6ExTfF~t* z3tc66R4P?$-noB%Sd17-^B4x< zP!I%VWAM)oK(Yw<4vri-i0SD%!J}9#S@-}lN*I1%V8FsRQ7ToiVZ)~X3cOhn-UwhG zAX7sTiS#DiZ1A3iKV;z>1aF}TJ_NH%c*^7h}&>4I#89*s(Lvj*Hc+ z*X3vCjS<-C4j?#M;c2xO@R0~7P(+l=Wh#*9Zmhm<4JPse&;0~(w{PDbd;a?v85zZ= zKmB=}Idcm2`dNbKuqQ>_Vbmw3>y1VO#wgr(-)c-uOymzfA#L5C{p@Fm%joE+vv=<< z-RUf&-CpDcFCHBP%`;D7Z{epvL+-ifzC6OGu30WUt63=QpZLTlzECL)&&|yd96#4( zibOf@5eXwh!s{7$9?K>vng&ZHXx`6Lip zW3!>Z;8P-iPjW1L8^vN7>uEnfKGw7FCTj|C_!Io%;-U@z3N~)sjPbFahG%28MUd(O zI?SMBtj+cqYpgHeQ=S5}*+!H5GATGy`AIGB}^;nzp z6HMFd@adbOojZ4OpBBI7O*h?iYI1UNR8vi0On<bl2vSE5VR&%+yPs1+VacR-)ayx}%9@I4=)ZH+NmOJN1o5Aas$ z{baJZ1&5rFZ4KbhUMO;q03Q^oqX*kH30`x;l<+`>f!1&P(eHuj< zy72UZm*YCMwoC^B!;Ef0y|{Bu0?W1y3oQI&}iBLs1Z5zS%}|VF^J%?=e*2 zSo^v2Q`og@cm3^eea+(!A3E?Gq=-gB2;>u<#)P1kH7ML$7MwH;yYLhZ=gtxQp8DG? z{Go#fehn#^O5r@hvpu?>!kv!qv%_akfn4wE_X(XQ)&R7mdNA$N32VPN1t zgD277|Nal~_rLD~Ie~u6l`|i8&v5$Q$dO^YgOrCdj zhF|2gtNnB;eC9QPii%gi`qg;e^PUIlYsblLZBKbm^?bX%3|re|fzQ4Oe&s7)!9ySV zP~-c4sI|@=d}wuHp$&RyY%wgJ>TQO${5JakrG8y)FjNj1V+lti-`050D&K< z0|)k={NM-P{n_2ScmDxESwIIdVBe}2>9WtWmhg~$OFvXx4u>OieFX<*3a z8o&XofmYpn6cluF2y)APC2it_MzKL$U~eeXYBig7e@_4|!wU_h10wiE(#QcFvasX2LJ#7 literal 0 HcmV?d00001 diff --git a/src/assets/img/files/video-64.png b/src/assets/img/files/video-64.png new file mode 100644 index 0000000000000000000000000000000000000000..570c4b2b319ff8aae875897308c42d1bb69d0984 GIT binary patch literal 4211 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 JPDHLkV1igq4wwJ{ literal 0 HcmV?d00001 diff --git a/src/assets/img/files/wav-64.png b/src/assets/img/files/wav-64.png new file mode 100644 index 0000000000000000000000000000000000000000..819781a9b564c188089b5796a99b661a94cd07da GIT binary patch literal 5410 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 M07*qoM6N<$g78l@)Bpeg literal 0 HcmV?d00001 diff --git a/src/assets/img/files/wmv-64.png b/src/assets/img/files/wmv-64.png new file mode 100644 index 0000000000000000000000000000000000000000..570c4b2b319ff8aae875897308c42d1bb69d0984 GIT binary patch literal 4211 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 JPDHLkV1igq4wwJ{ literal 0 HcmV?d00001 diff --git a/src/assets/img/files/writer-64.png b/src/assets/img/files/writer-64.png new file mode 100644 index 0000000000000000000000000000000000000000..6285b6ffafd8b1c30b20ec68df6aaaaac378d21d GIT binary patch literal 2836 zcmV+v3+wcWP)g_n5<+kC`#}F@=+3=DXm>%#7hj$IQ&^Fee)?86{a7&2-l% z+x_m%n|R(zI`6am-&d{{7=BgNHPxdPRb?YNr--m>83_ju9^&*YN98kL+po=qCvN`O|6O_WkDhQ8s%)YZ z#Y@4(KfiSBNx${TTtg%%GB?)+Yu8My zgJgZ};)GJ6fNed_^QIe|{+lnLP(An5!{ETL!J**)=E=#i zSN8((m;D4_d}Ss2MjR*47Lg3RU~Gp!|MUVke?4M8vNW}b(F9$~yz>D_Adaz7!gM)c zvIl?R3s<@-yxq!g->RYg^I z02{-9W|3RJ88e?iE38sbhq?ftKfl?)D(tM5*^%tyyce{&?3d4GG7Fzw>_PI=^pUS2ww#;MAR7~n;C^$g-Zi1Q2_u!Yg{o6hG*w87I2~pGuAP^W2FIcrq zRYU60Ue0^wBA5N#X-w|iwN>!hjWy6!fWVk__)jl!$4s&Y9~CPE_&?4VZ~zVs3{GLH zY^Zmh;DTo?aOuySvi0x^8BUDvj=~uyPEkbf%uXV5Cn`t9avSS zio#?M{`{xUbLmf>+=JgeBJfI{Odh;(9scA~i`?;@nEes7L!WuyRRjS)Tv##eEE*=G zC%ItHp&tB+qXUnk*oGzJjF z2mr!*5E2!p0>^mkaW34okKg~X7f~NUc;_6&QsK~4z4k)j5GjSv|Cvj{W5t~YJ9fX| z@-NNRr}i$lXa^O4omUV8Y7h|+C9%SEQ5kDJ!9_jz-y2bQ`~u&%vI>9bcdkvB!;VtO z)t`N6iMECWYH=2fF<>0(K#Za5gkwsMYIAQ7es(SMlOqa$xIP#*4m82bh=koSQ`R6v zY>cWRAvnOW9)zy#!IzavqnG*J`?fEQ3EKo-b7z6DUN5IA02&>K#tB%9raEQU!7&wKyfw?kPdm81;1&Ns$tu*uftL!9 zKGFI2#}1AjGmNkFGXL~L{Qi%hxUDj;h1s+JbQ&pe)jaf;H@gx z|MP8p{+stwEtl9VCDw9OZH#|?`%4Fd_iH7;3PRGapt!^`Q7+)xPjdeAI$ZLLXZL+G z!>RV(do0g6u|m06V6)^@32}qLXRiYJ6<|qMfC&Ojr z=2ORE%%igh!k54DRHk~}-n!wsePW|NDj+AmE8hgT1u+XEIq1$ag6PW_ob_J*)fdan`%gJ#~rrZhp{ z^fm=P_hyKf>_423 z3=V(&zutbtCc%Gv^ECs*qnZL3?Bj7j20hpu;Yo0CcpdJOp`lN8M`RKdEes8x`qF;@ zM20`o7J~wXo^9ox&%&#M<^;mz^?>i?Jg*v$3cPnZ=oT0r{>Fd1WAG-J+w`v91b9&L z6>cUxy1A3!=D@4>0|Y?*j}VnXPj#6lxFroQ3-A3P6`lh+C87myyg#s zhI|cwo*yZA^*+A|ZYumu|FPNx?>i!s;NQOfis9h#evkktAVXw+Vyf2!PpDEZhMPMH ziW?dp#arJ^&<_G41DE~p|J>PFXAy*a>EN&E@bK#Y4blXM-t%L{@{!r|GAul(Q{rxd zsvB?%Y}1C9h3`aNRGsN*uqL=|;B&gZQzh!bce^orb|3SNFbwAU4}k9hqX_R`$GOzN z;=d{as;EK|Cxl_h%F3a~{`607dRe_bsXb)_pt((8bm4`#8&j)Qn3xzRjuW5h1Q06K z5=j!XysSL($Rkg_;dQS#|6AYs#>{}DFvtj&y&$w&Ey56vJ!TJ`PPA6ytyY9}j)__o zRma@?GT-^mU9;D|^|cq>fB(H-Ef$MxB%?sq&=KDAz!Q5PCk%unu`D-QG#c%7_=zee z5ccn%=R4n?nZ5RHuUm!xTo8oh$*^GrMi5>VL2#-pEiMv8G0kSXXC0hs4}J_|*5POF zx_kE8x4-_P`|rEwvtbypk*u|=mOlt7AS0G}?{VtZ_I#t2h6fM7nt2Z&W^V5(Ns_Gk zey#$5jMk;jAJ5l()Q#6N*em-y_!PJ!jxDN6R-rKl=UfI}MsUbUPlJkxtYzN0Bp2Q} z=h^aQn#~5r2ti;-VUFyPR@WVsN{Jwl@4{b~f?tJBk|Y;CEpz6XXL9Ye*Ky#$!L=Y5 z5r7ee_ulX20|yqEnYnBBy0^dXqWgOA#+ZEY90^{vT8*1;zJ=a@pPyb>Y~0i7#8V}CFQ45z4E0uf2iItkoOh%>V^&dd3-NocLFN{kMO%d)L!Xj^i#~l09mIieGrwcjhy% zXRr5UtAQj*R!tsw;J!!yQUlFZtB1jKtt`Mh;jn(^QZK|~P|x3sj}h@z+q6jRWe2|$vvt_N>> zi~Mu34*=Op@WH?Uq(Hs*mI2Q(vOYfu=wSk(-o%@xEE|Mu=_lS?$}6BkzH+nYjeNj- m(0(c)`*?tAafb_PR{swIiXmdG)Mi=$0000V3J+{ literal 0 HcmV?d00001 diff --git a/src/classes/sqlitedb.ts b/src/classes/sqlitedb.ts index 04f9fa6f6..3c6c248d5 100644 --- a/src/classes/sqlitedb.ts +++ b/src/classes/sqlitedb.ts @@ -329,6 +329,21 @@ export class SQLiteDB { }); } + /** + * Format the data to insert in the database. Removes undefined entries so they are stored as null instead of 'undefined'. + * + * @param {object} data Data to insert. + */ + protected formatDataToInsert(data: object) : void { + // Remove undefined entries and convert null to "NULL". + for (let name in data) { + let value = data[name]; + if (typeof value == 'undefined') { + delete data[name]; + } + } + } + /** * Get all the records from a table. * @@ -585,6 +600,8 @@ export class SQLiteDB { * @return {any[]} Array with the SQL query and the params. */ protected getSqlInsertQuery(table: string, data: object) : any[] { + this.formatDataToInsert(data); + let keys = Object.keys(data), fields = keys.join(','), questionMarks = ',?'.repeat(keys.length).substr(1); @@ -773,6 +790,8 @@ export class SQLiteDB { sql, params; + this.formatDataToInsert(data); + for (let key in data) { sets.push(`${key} = ?`); } diff --git a/src/components/components.module.ts b/src/components/components.module.ts index c814ce0ec..2b462dd4d 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -24,6 +24,7 @@ import { CoreIframeComponent } from './iframe/iframe'; import { CoreProgressBarComponent } from './progress-bar/progress-bar'; import { CoreEmptyBoxComponent } from './empty-box/empty-box'; import { CoreSearchBoxComponent } from './search-box/search-box'; +import { CoreFileComponent } from './file/file'; @NgModule({ declarations: [ @@ -34,7 +35,8 @@ import { CoreSearchBoxComponent } from './search-box/search-box'; CoreIframeComponent, CoreProgressBarComponent, CoreEmptyBoxComponent, - CoreSearchBoxComponent + CoreSearchBoxComponent, + CoreFileComponent ], imports: [ IonicModule, @@ -49,7 +51,8 @@ import { CoreSearchBoxComponent } from './search-box/search-box'; CoreIframeComponent, CoreProgressBarComponent, CoreEmptyBoxComponent, - CoreSearchBoxComponent + CoreSearchBoxComponent, + CoreFileComponent ] }) export class CoreComponentsModule {} diff --git a/src/components/file/file.html b/src/components/file/file.html new file mode 100644 index 000000000..e4dab9efb --- /dev/null +++ b/src/components/file/file.html @@ -0,0 +1,13 @@ + + +

{{fileName}}

+
+ + +
+ +
diff --git a/src/components/file/file.scss b/src/components/file/file.scss new file mode 100644 index 000000000..6279a3b37 --- /dev/null +++ b/src/components/file/file.scss @@ -0,0 +1,28 @@ +core-loading { + .mm-loading-container { + width: 100%; + text-align: center; + padding-top: 10px; + clear: both; + } + + .mm-loading-content { + padding-bottom: 1px; /* This makes height be real */ + } + + &.mm-loading-noheight .mm-loading-content { + height: auto; + } +} + +.scroll-content > .padding > core-loading > .mm-loading-container, +ion-content[padding] > .scroll-content > core-loading > .mm-loading-container, +.mm-loading-center .mm-loading-container { + display: table; + + .mm-loading-spinner { + display: table-cell; + text-align: center; + vertical-align: middle; + } +} diff --git a/src/components/file/file.ts b/src/components/file/file.ts new file mode 100644 index 000000000..0506bea4f --- /dev/null +++ b/src/components/file/file.ts @@ -0,0 +1,291 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Input, Output, OnInit, OnDestroy, EventEmitter } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '../../providers/app'; +import { CoreEventsProvider } from '../../providers/events'; +import { CoreFileProvider } from '../../providers/file'; +import { CoreFilepoolProvider } from '../../providers/filepool'; +import { CoreSitesProvider } from '../../providers/sites'; +import { CoreDomUtilsProvider } from '../../providers/utils/dom'; +import { CoreMimetypeUtilsProvider } from '../../providers/utils/mimetype'; +import { CoreUtilsProvider } from '../../providers/utils/utils'; +import { CoreConstants } from '../../core/constants'; + +/** + * Component to handle a remote file. Shows the file name, icon (depending on mimetype) and a button + * to download/refresh it. + */ +@Component({ + selector: 'core-file', + templateUrl: 'file.html' +}) +export class CoreFileComponent implements OnInit, OnDestroy { + @Input() file: any; // The file. Must have a property 'filename' and a 'fileurl' or 'url' + @Input() component?: string; // Component the file belongs to. + @Input() componentId?: string|number; // Component ID. + @Input() timemodified?: number; // If set, the value will be used to check if the file is outdated. + @Input() canDelete?: boolean|string; // Whether file can be deleted. + @Input() alwaysDownload?: boolean|string; // Whether it should always display the refresh button when the file is downloaded. + // Use it for files that you cannot determine if they're outdated or not. + @Input() canDownload?: boolean|string = true; // Whether file can be downloaded. + @Output() onDelete?: EventEmitter; // Will notify when the delete button is clicked. + + isDownloaded: boolean; + isDownloading: boolean; + showDownload: boolean; + fileIcon: string; + fileName: string; + + protected fileUrl: string; + protected siteId: string; + protected fileSize: number; + protected observer; + + constructor(private translate: TranslateService, private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, + private domUtils: CoreDomUtilsProvider, private filepoolProvider: CoreFilepoolProvider, + private fileProvider: CoreFileProvider, private appProvider: CoreAppProvider, + private mimeUtils: CoreMimetypeUtilsProvider, private eventsProvider: CoreEventsProvider) { + this.onDelete = new EventEmitter(); + } + + /** + * Component being initialized. + */ + ngOnInit() { + this.canDelete = this.utils.isTrueOrOne(this.canDelete); + this.alwaysDownload = this.utils.isTrueOrOne(this.alwaysDownload); + this.canDownload = this.utils.isTrueOrOne(this.canDownload); + this.timemodified = this.timemodified || 0; + + this.fileUrl = this.file.fileurl || this.file.url; + this.siteId = this.sitesProvider.getCurrentSiteId(); + this.fileSize = this.file.filesize; + this.fileName = this.file.filename; + + if (this.file.isexternalfile) { + this.alwaysDownload = true; // Always show the download button in external files. + } + + this.fileIcon = this.mimeUtils.getFileIcon(this.file.filename); + + if (this.canDownload) { + this.calculateState(); + + // Update state when receiving events about this file. + this.filepoolProvider.getFileEventNameByUrl(this.siteId, this.fileUrl).then((eventName) => { + this.observer = this.eventsProvider.on(eventName, () => { + this.calculateState(); + }); + }); + } + } + + /** + * Convenience function to get the file state and set variables based on it. + * + * @return {Promise} Promise resolved when state has been calculated. + */ + protected calculateState() : Promise { + return this.filepoolProvider.getFileStateByUrl(this.siteId, this.fileUrl, this.timemodified).then((state) => { + let canDownload = this.sitesProvider.getCurrentSite().canDownloadFiles(); + + this.isDownloaded = state === CoreConstants.downloaded || state === CoreConstants.outdated; + this.isDownloading = canDownload && state === CoreConstants.downloading; + this.showDownload = canDownload && (state === CoreConstants.notDownloaded || state === CoreConstants.outdated || + (this.alwaysDownload && state === CoreConstants.downloaded)); + }); + } + + /** + * Download the file. + * + * @return {Promise} Promise resolved when file is downloaded. + */ + protected downloadFile() : Promise { + if (!this.sitesProvider.getCurrentSite().canDownloadFiles()) { + this.domUtils.showErrorModal('core.cannotdownloadfiles', true); + return Promise.reject(null); + } + + this.isDownloading = true; + return this.filepoolProvider.downloadUrl(this.siteId, this.fileUrl, false, this.component, this.componentId, + this.timemodified, undefined, undefined, this.file).catch(() => { + + // Call calculateState to make sure we have the right state. + return this.calculateState().then(() => { + if (this.isDownloaded) { + return this.filepoolProvider.getInternalUrlByUrl(this.siteId, this.fileUrl); + } else { + return Promise.reject(null); + } + }); + }); + } + + /** + * Convenience function to open a file, downloading it if needed. + * + * @return {Promise} Promise resolved when file is opened. + */ + protected openFile() : Promise { + let fixedUrl = this.sitesProvider.getCurrentSite().fixPluginfileURL(this.fileUrl), + promise; + + if (this.fileProvider.isAvailable()) { + promise = Promise.resolve().then(() => { + // The file system is available. + let isWifi = !this.appProvider.isNetworkAccessLimited(), + isOnline = this.appProvider.isOnline(); + + if (this.isDownloaded && !this.showDownload) { + // File is downloaded, get the local file URL. + return this.filepoolProvider.getUrlByUrl(this.siteId, this.fileUrl, + this.component, this.componentId, this.timemodified, false, false, this.file); + } else { + if (!isOnline && !this.isDownloaded) { + // Not downloaded and user is offline, reject. + return Promise.reject(this.translate.instant('core.networkerrormsg')); + } + + let isDownloading = this.isDownloading; + this.isDownloading = true; // This check could take a while, show spinner. + return this.filepoolProvider.shouldDownloadBeforeOpen(fixedUrl, this.fileSize).then(() => { + if (isDownloading) { + // It's already downloading, stop. + return; + } + // Download and then return the local URL. + return this.downloadFile(); + }, () => { + // Start the download if in wifi, but return the URL right away so the file is opened. + if (isWifi && isOnline) { + this.downloadFile(); + } + + if (isDownloading || !this.isDownloaded || isOnline) { + // Not downloaded or outdated and online, return the online URL. + return fixedUrl; + } else { + // Outdated but offline, so we return the local URL. + return this.filepoolProvider.getUrlByUrl(this.siteId, this.fileUrl, + this.component, this.componentId, this.timemodified, false, false, this.file); + } + }); + } + }); + } else { + // Use the online URL. + promise = Promise.resolve(fixedUrl); + } + + return promise.then((url) => { + if (!url) { + return; + } + + if (url.indexOf('http') === 0) { + return this.utils.openOnlineFile(url).catch((error) => { + // Error opening the file, some apps don't allow opening online files. + if (!this.fileProvider.isAvailable()) { + return Promise.reject(error); + } else if (this.isDownloading) { + return Promise.reject(this.translate.instant('core.erroropenfiledownloading')); + } + + let subPromise; + + if (status === CoreConstants.notDownloaded) { + // File is not downloaded, download and then return the local URL. + subPromise = this.downloadFile(); + } else { + // File is outdated and can't be opened in online, return the local URL. + subPromise = this.filepoolProvider.getInternalUrlByUrl(this.siteId, this.fileUrl); + } + + return subPromise.then((url) => { + return this.utils.openFile(url); + }); + }); + } else { + return this.utils.openFile(url); + } + }); + } + + /** + * Download a file and, optionally, open it afterwards. + * + * @param {Event} e Click event. + * @param {boolean} openAfterDownload Whether the file should be opened after download. + */ + download(e: Event, openAfterDownload: boolean) : void { + e.preventDefault(); + e.stopPropagation(); + + let promise; + + if (this.isDownloading && !openAfterDownload) { + return; + } + + if (!this.appProvider.isOnline() && (!openAfterDownload || (openAfterDownload && !this.isDownloaded))) { + this.domUtils.showErrorModal('core.networkerrormsg', true); + return; + } + + if (openAfterDownload) { + // File needs to be opened now. + this.openFile().catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + }); + } else { + // File doesn't need to be opened (it's a prefetch). Show confirm modal if file size is defined and it's big. + promise = this.fileSize ? this.domUtils.confirmDownloadSize({size: this.fileSize, total: true}) : Promise.resolve(); + promise.then(() => { + // User confirmed, add the file to queue. + this.filepoolProvider.invalidateFileByUrl(this.siteId, this.fileUrl).finally(() => { + this.isDownloading = true; + this.filepoolProvider.addToQueueByUrl(this.siteId, this.fileUrl, this.component, + this.componentId, this.timemodified, undefined, undefined, 0, this.file).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + this.calculateState(); + }); + }); + }); + } + }; + + /** + * Delete the file. + * + * @param {Event} e Click event. + */ + deleteFile(e: Event) : void { + e.preventDefault(); + e.stopPropagation(); + + if (this.canDelete) { + this.onDelete.emit(); + } + } + + /** + * Component destroyed. + */ + ngOnDestroy() { + this.observer && this.observer.off(); + } +} diff --git a/src/core/courses/pages/course-preview/course-preview.html b/src/core/courses/pages/course-preview/course-preview.html index 143e07f10..9bda961bf 100644 --- a/src/core/courses/pages/course-preview/course-preview.html +++ b/src/core/courses/pages/course-preview/course-preview.html @@ -25,7 +25,7 @@

{{ 'core.teachers' | translate }}

{{contact.fullname}}

- +

{{ instance.name }}

diff --git a/src/core/emulator/providers/file.ts b/src/core/emulator/providers/file.ts index 4eef27146..70c61ba0d 100644 --- a/src/core/emulator/providers/file.ts +++ b/src/core/emulator/providers/file.ts @@ -350,7 +350,6 @@ export class FileMock extends File { (navigator).webkitPersistentStorage.requestQuota(500 * 1024 * 1024, (granted) => { window.requestFileSystem(LocalFileSystem.PERSISTENT, granted, (entry) => { basePath = entry.root.toURL(); - // this.fileProvider.setHTMLBasePath(basePath); resolve(basePath); }, reject); }, reject); diff --git a/src/providers/file.ts b/src/providers/file.ts index 895328ac9..bfe7f9c4c 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -98,7 +98,7 @@ export class CoreFileProvider { * @return {boolean} Whether the plugin is available. */ isAvailable() : boolean { - return typeof window.resolveLocalFileSystemURL !== 'undefined' && typeof FileTransfer !== 'undefined'; + return typeof window.resolveLocalFileSystemURL !== 'undefined'; } /** diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts index a27518484..3a122ace8 100644 --- a/src/providers/filepool.ts +++ b/src/providers/filepool.ts @@ -327,7 +327,7 @@ export class CoreFilepoolProvider { component: component, componentId: componentId || '' }; - return db.insertOrUpdateRecord(this.LINKS_TABLE, newEntry, null); + return db.insertOrUpdateRecord(this.LINKS_TABLE, newEntry, undefined); }); } @@ -410,7 +410,7 @@ export class CoreFilepoolProvider { * @return {Promise} Promise resolved when the file is downloaded. */ protected addToQueue(siteId: string, fileId: string, url: string, priority: number, revision: number, timemodified: number, - filePath: string, options: any = {}, link?: any) : Promise { + filePath: string, onProgress?: (event: any) => any, options: any = {}, link?: any) : Promise { this.logger.debug(`Adding ${fileId} to the queue`); return this.appDB.insertRecord(this.QUEUE_TABLE, { @@ -429,7 +429,7 @@ export class CoreFilepoolProvider { // Check if the queue is running. this.checkQueueProcessing(); this.notifyFileDownloading(siteId, fileId); - return this.getQueuePromise(siteId, fileId); + return this.getQueuePromise(siteId, fileId, true, onProgress); }); } @@ -479,8 +479,7 @@ export class CoreFilepoolProvider { // Retrieve the queue deferred now if it exists to prevent errors if file is removed from queue // while we're checking if the file is in queue. - queueDeferred = this.getQueueDeferred(siteId, fileId, false); - queueDeferred.onProgress = onProgress; + queueDeferred = this.getQueueDeferred(siteId, fileId, false, onProgress); return this.hasFileInQueue(siteId, fileId).then((entry: CoreFilepoolQueueEntry) => { let foundLink = false, @@ -530,7 +529,7 @@ export class CoreFilepoolProvider { // Update only when required. this.logger.debug(`Updating file ${fileId} which is already in queue`); return this.appDB.updateRecords(this.QUEUE_TABLE, newData, primaryKey).then(() => { - return this.getQueuePromise(siteId, fileId); + return this.getQueuePromise(siteId, fileId, true, onProgress); }); } @@ -540,14 +539,17 @@ export class CoreFilepoolProvider { // might have finished now and the deferred wouldn't be in the array anymore. return queueDeferred.promise; } else { - return this.getQueuePromise(siteId, fileId); + // Create a new deferred and return its promise. + return this.getQueuePromise(siteId, fileId, true, onProgress); } } else { - return this.addToQueue(siteId, fileId, fileUrl, priority, revision, timemodified, filePath, options, link); + return this.addToQueue( + siteId, fileId, fileUrl, priority, revision, timemodified, filePath, onProgress, options, link); } }, () => { // Unsure why we could not get the record, let's add to the queue anyway. - return this.addToQueue(siteId, fileId, fileUrl, priority, revision, timemodified, filePath, options, link); + return this.addToQueue( + siteId, fileId, fileUrl, priority, revision, timemodified, filePath, onProgress, options, link); }); }); }); @@ -596,15 +598,17 @@ export class CoreFilepoolProvider { // Check if the file should be downloaded. if (sizeUnknown) { if (downloadUnknown && isWifi) { - return this.addToQueueByUrl(siteId, fileUrl, component, componentId, timemodified, null, null, 0, options); + return this.addToQueueByUrl( + siteId, fileUrl, component, componentId, timemodified, undefined, undefined, 0, options); } } else if (size <= this.DOWNLOAD_THRESHOLD || (isWifi && size <= this.WIFI_DOWNLOAD_THRESHOLD)) { - return this.addToQueueByUrl(siteId, fileUrl, component, componentId, timemodified, null, null, 0, options); + return this.addToQueueByUrl( + siteId, fileUrl, component, componentId, timemodified, undefined, undefined, 0, options); } }); } else { // No need to check size, just add it to the queue. - return this.addToQueueByUrl(siteId, fileUrl, component, componentId, timemodified, null, null, 0, options); + return this.addToQueueByUrl(siteId, fileUrl, component, componentId, timemodified, undefined, undefined, 0, options); } } @@ -817,9 +821,11 @@ export class CoreFilepoolProvider { } if (prefetch) { - promises.push(this.addToQueueByUrl(siteId, url, component, componentId, timemodified, null, null, 0, options)); + promises.push(this.addToQueueByUrl( + siteId, url, component, componentId, timemodified, undefined, undefined, 0, options)); } else { - promises.push(this.downloadUrl(siteId, url, ignoreStale, component, componentId, timemodified, null, null, options)); + promises.push(this.downloadUrl( + siteId, url, ignoreStale, component, componentId, timemodified, undefined, undefined, options)); } }); @@ -901,7 +907,7 @@ export class CoreFilepoolProvider { if (prefetch) { promise = this.addToQueueByUrl( - siteId, fileUrl, component, componentId, file.timemodified, path, null, 0, options); + siteId, fileUrl, component, componentId, file.timemodified, path, undefined, 0, options); } else { promise = this.downloadUrl( siteId, fileUrl, false, component, componentId, file.timemodified, onFileProgress, path, options); @@ -1816,9 +1822,10 @@ export class CoreFilepoolProvider { * @param {string} siteId The site ID. * @param {string} fileId The file ID. * @param {boolean} [create=true] True if it should create a new deferred if it doesn't exist. + * @param {Function} [onProgress] Function to call on progress. * @return {any} Deferred. */ - protected getQueueDeferred(siteId: string, fileId: string, create = true): any { + protected getQueueDeferred(siteId: string, fileId: string, create = true, onProgress?: (event: any) => any): any { if (!this.queueDeferreds[siteId]) { if (!create) { return; @@ -1831,6 +1838,11 @@ export class CoreFilepoolProvider { } this.queueDeferreds[siteId][fileId] = this.utils.promiseDefer(); } + + if (onProgress) { + this.queueDeferreds[siteId][fileId].onProgress = onProgress; + } + return this.queueDeferreds[siteId][fileId]; } @@ -1854,10 +1866,11 @@ export class CoreFilepoolProvider { * @param {string} siteId The site ID. * @param {string} fileId The file ID. * @param {boolean} [create=true] True if it should create a new promise if it doesn't exist. + * @param {Function} [onProgress] Function to call on progress. * @return {Promise} Promise. */ - protected getQueuePromise(siteId: string, fileId: string, create = true) : Promise { - return this.getQueueDeferred(siteId, fileId, create).promise; + protected getQueuePromise(siteId: string, fileId: string, create = true, onProgress?: (event: any) => any) : Promise { + return this.getQueueDeferred(siteId, fileId, create, onProgress).promise; } /** @@ -2248,7 +2261,9 @@ export class CoreFilepoolProvider { promise.then(() => { // All good, we schedule next execution. - setTimeout(this.processQueue, this.QUEUE_PROCESS_INTERVAL); + setTimeout(() => { + this.processQueue(); + }, this.QUEUE_PROCESS_INTERVAL); }, (error) => { @@ -2270,7 +2285,7 @@ export class CoreFilepoolProvider { * @return {Promise} Resolved on success. Rejected on failure. */ protected processImportantQueueItem() : Promise { - return this.appDB.getRecords(this.QUEUE_TABLE, null, 'priority DESC, added ASC', null, 0, 1).then((items) => { + return this.appDB.getRecords(this.QUEUE_TABLE, undefined, 'priority DESC, added ASC', undefined, 0, 1).then((items) => { let item = items.pop(); if (!item) { return Promise.reject(this.ERR_QUEUE_IS_EMPTY); @@ -2290,16 +2305,17 @@ export class CoreFilepoolProvider { * @return {Promise} Resolved on success. Rejected on failure. */ protected processQueueItem(item: CoreFilepoolQueueEntry) : Promise { + // Cast optional fields to undefined instead of null. let siteId = item.siteId, fileId = item.fileId, fileUrl = item.url, options = { - revision: item.revision, - timemodified: item.timemodified, - isexternalfile: item.isexternalfile, - repositorytype: item.repositorytype + revision: item.revision || undefined, + timemodified: item.timemodified || undefined, + isexternalfile: item.isexternalfile || undefined, + repositorytype: item.repositorytype || undefined }, - filePath = item.path, + filePath = item.path || undefined, links = item.links || []; this.logger.debug('Processing queue item: ' + siteId + ', ' + fileId); @@ -2335,7 +2351,7 @@ export class CoreFilepoolProvider { // Whoops, we have an error... let dropFromQueue = false; - if (typeof errorObject != 'undefined' && errorObject.source === fileUrl) { + if (errorObject && errorObject.source === fileUrl) { // This is most likely a FileTransfer error. if (errorObject.code === 1) { // FILE_NOT_FOUND_ERR. // The file was not found, most likely a 404, we remove from queue. diff --git a/src/providers/utils/mimetype.ts b/src/providers/utils/mimetype.ts index 4d55fff19..589c14b32 100644 --- a/src/providers/utils/mimetype.ts +++ b/src/providers/utils/mimetype.ts @@ -185,7 +185,7 @@ export class CoreMimetypeUtilsProvider { } } - return 'img/files/' + icon + '-64.png'; + return 'assets/img/files/' + icon + '-64.png'; } /** @@ -194,7 +194,7 @@ export class CoreMimetypeUtilsProvider { * @return {string} The path to a folder icon. */ getFolderIcon() : string { - return 'img/files/folder-64.png'; + return 'assets/img/files/folder-64.png'; } /** diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 7aff83671..fd6c06cbb 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -171,3 +171,10 @@ $item-wp-avatar-size: 54px; @import "roboto"; @import "noto-sans"; + +// Moodle Mobile variables +// -------------------------------------------------- + +// Small avatar ideal for icons. +$item-media-width: 32px !default; +$item-media-height: 32px !default; From fc85a5b9c68c4cd763b30e3853e3f037380e830a Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 20 Dec 2017 12:18:49 +0100 Subject: [PATCH 16/24] MOBILE-2302 courses: Implement courses delegate --- src/core/courses/courses.module.ts | 4 +- .../pages/course-preview/course-preview.ts | 22 +- src/core/courses/providers/courses.ts | 3 + src/core/courses/providers/delegate.ts | 495 ++++++++++++++++++ src/core/mainmenu/providers/delegate.ts | 4 +- src/providers/events.ts | 4 +- src/providers/plugin-file-delegate.ts | 4 +- 7 files changed, 515 insertions(+), 21 deletions(-) create mode 100644 src/core/courses/providers/delegate.ts diff --git a/src/core/courses/courses.module.ts b/src/core/courses/courses.module.ts index 48462c473..39d01fba1 100644 --- a/src/core/courses/courses.module.ts +++ b/src/core/courses/courses.module.ts @@ -16,6 +16,7 @@ import { NgModule } from '@angular/core'; import { CoreCoursesProvider } from './providers/courses'; import { CoreCoursesMainMenuHandler } from './providers/handlers'; import { CoreCoursesMyOverviewProvider } from './providers/my-overview'; +import { CoreCoursesDelegate } from './providers/delegate'; import { CoreMainMenuDelegate } from '../mainmenu/providers/delegate'; @NgModule({ @@ -25,7 +26,8 @@ import { CoreMainMenuDelegate } from '../mainmenu/providers/delegate'; providers: [ CoreCoursesProvider, CoreCoursesMainMenuHandler, - CoreCoursesMyOverviewProvider + CoreCoursesMyOverviewProvider, + CoreCoursesDelegate ], exports: [] }) diff --git a/src/core/courses/pages/course-preview/course-preview.ts b/src/core/courses/pages/course-preview/course-preview.ts index 2171fc0d3..1076440cd 100644 --- a/src/core/courses/pages/course-preview/course-preview.ts +++ b/src/core/courses/pages/course-preview/course-preview.ts @@ -21,6 +21,7 @@ import { CoreSitesProvider } from '../../../../providers/sites'; import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; import { CoreCoursesProvider } from '../../providers/courses'; +import { CoreCoursesDelegate } from '../../providers/delegate'; /** * Page that allows "previewing" a course and enrolling in it if enabled and not enrolled. @@ -58,7 +59,8 @@ export class CoreCoursesCoursePreviewPage implements OnDestroy { constructor(private navCtrl: NavController, navParams: NavParams, private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider, appProvider: CoreAppProvider, private coursesProvider: CoreCoursesProvider, private platform: Platform, private modalCtrl: ModalController, - private translate: TranslateService, private eventsProvider: CoreEventsProvider) { + private translate: TranslateService, private eventsProvider: CoreEventsProvider, + private coursesDelegate: CoreCoursesDelegate) { this.course = navParams.get('course'); this.isMobile = appProvider.isMobile(); this.isDesktop = appProvider.isDesktop(); @@ -164,12 +166,12 @@ export class CoreCoursesCoursePreviewPage implements OnDestroy { // Success retrieving the course, we can assume the user has permissions to view it. this.course.fullname = course.fullname || this.course.fullname; this.course.summary = course.summary || this.course.summary; - return this.loadCourseNavHandlers(refresh, false); + return this.loadCourseHandlers(refresh, false); }).catch(() => { // The user is not an admin/manager. Check if we can provide guest access to the course. return this.canAccessAsGuest().then((passwordRequired) => { if (!passwordRequired) { - return this.loadCourseNavHandlers(refresh, true); + return this.loadCourseHandlers(refresh, true); } else { return Promise.reject(null); } @@ -189,20 +191,12 @@ export class CoreCoursesCoursePreviewPage implements OnDestroy { * @param {boolean} refresh Whether the user is refreshing the data. * @param {boolean} guest Whether it's guest access. */ - protected loadCourseNavHandlers(refresh: boolean, guest: boolean) : Promise { - // @todo: Get the handlers to be shown. - return new Promise((resolve, reject) => { - this.course._handlers = []; + protected loadCourseHandlers(refresh: boolean, guest: boolean) : Promise { + return this.coursesDelegate.getHandlersToDisplay(this.course, refresh, guest, true).then((handlers) => { + this.course._handlers = handlers; this.handlersShouldBeShown = true; this.handlersLoaded = true; - resolve(); }); - // return $mmCoursesDelegate.getNavHandlersToDisplay(course, refresh, guest, true).then(function(handlers) { - // course._handlers = handlers; - // $scope.handlersShouldBeShown = true; - // }).catch(() => { - - // }); } /** diff --git a/src/core/courses/providers/courses.ts b/src/core/courses/providers/courses.ts index d3461af75..9e8e0623b 100644 --- a/src/core/courses/providers/courses.ts +++ b/src/core/courses/providers/courses.ts @@ -25,6 +25,9 @@ export class CoreCoursesProvider { public static SEARCH_PER_PAGE = 20; public static ENROL_INVALID_KEY = 'CoreCoursesEnrolInvalidKey'; public static EVENT_MY_COURSES_UPDATED = 'courses_my_courses_updated'; + public static EVENT_MY_COURSES_REFRESHED = 'courses_my_courses_refreshed'; + public static ACCESS_GUEST = 'courses_access_guest'; + public static ACCESS_DEFAULT = 'courses_access_default'; protected logger; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider) { diff --git a/src/core/courses/providers/delegate.ts b/src/core/courses/providers/delegate.ts new file mode 100644 index 000000000..68c11e4dd --- /dev/null +++ b/src/core/courses/providers/delegate.ts @@ -0,0 +1,495 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Injectable } from '@angular/core'; +import { CoreEventsProvider } from '../../../providers/events'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreUtilsProvider, PromiseDefer } from '../../../providers/utils/utils'; +import { CoreCoursesProvider } from './courses'; + +export interface CoreCoursesHandler { + name: string; // Name of the handler. + priority: number; // The highest priority is displayed first. + isEnabled(): boolean|Promise; // Whether or not the handler is enabled on a site level. + isEnabledForCourse(courseId: number, accessData: any, navOptions?: any, admOptions?: any) : + boolean|Promise; // Whether the handler is enabled on a course level. For perfomance reasons, do NOT call + // WebServices in here, call them in shouldDisplayForCourse. + shouldDisplayForCourse(courseId: number, accessData: any, navOptions?: any, admOptions?: any) : + boolean|Promise; // Whether the handler should be displayed in a course. If not implemented, assume it's true. + getDisplayData?(courseId: number): CoreCoursesHandlerData; // Returns the data needed to render the handler. + invalidateEnabledForCourse?(courseId: number, navOptions?: any, admOptions?: any) : Promise; // Should invalidate data + // to determine if handler is enabled for a certain course. + prefetch?(course: any) : Promise; // Will be called when a course is downloaded, and it should prefetch all the data + // to be able to see the addon in offline. +}; + +export interface CoreCoursesHandlerData { + title: string; // Title to display for the handler. + icon: string; // Name of the icon to display for the handler. + class?: string; // Class to add to the displayed handler. + action(course: any): void; // Action to perform when the handler is clicked. +}; + +export interface CoreCoursesHandlerToDisplay { + data: CoreCoursesHandlerData; // Data to display. + priority?: number; // Handler's priority. + prefetch?(course: any) : Promise; // Function to prefetch the handler. +}; + +/** + * Service to interact with plugins to be shown in each course. + */ +@Injectable() +export class CoreCoursesDelegate { + protected logger; + protected handlers: {[s: string]: CoreCoursesHandler} = {}; // All registered handlers. + protected enabledHandlers: {[s: string]: CoreCoursesHandler} = {}; // Handlers enabled for the current site. + protected loaded: {[courseId: number]: boolean} = {}; + protected lastUpdateHandlersStart: number; + protected lastUpdateHandlersForCoursesStart: any = {}; + protected coursesHandlers: {[courseId: number]: { + access?: any, navOptions?: any, admOptions?: any, deferred?: PromiseDefer, enabledHandlers?: CoreCoursesHandler[]}} = {}; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private eventsProvider: CoreEventsProvider, + private coursesProvider: CoreCoursesProvider, private utils: CoreUtilsProvider) { + this.logger = logger.getInstance('CoreMainMenuDelegate'); + + eventsProvider.on(CoreEventsProvider.LOGIN, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.SITE_UPDATED, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.REMOTE_ADDONS_LOADED, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.LOGOUT, () => { + this.clearCoursesHandlers(); + }); + } + + /** + * Check if handlers are loaded for a certain course. + * + * @param {number} courseId The course ID to check. + * @return {boolean} True if handlers are loaded, false otherwise. + */ + areHandlersLoaded(courseId: number) : boolean { + return !!this.loaded[courseId]; + } + + /** + * Clear all courses handlers. + * + * @param {number} [courseId] The course ID. If not defined, all handlers will be cleared. + */ + protected clearCoursesHandlers(courseId?: number) : void { + if (courseId) { + this.loaded[courseId] = false; + delete this.coursesHandlers[courseId]; + } else { + this.loaded = {}; + this.coursesHandlers = {}; + } + } + + /** + * Clear all courses handlers and invalidate its options. + * + * @param {number} [courseId] The course ID. If not defined, all handlers will be cleared. + * @return {Promise} Promise resolved when done. + */ + clearAndInvalidateCoursesOptions(courseId?: number) : Promise { + var promises = []; + + this.eventsProvider.trigger(CoreCoursesProvider.EVENT_MY_COURSES_REFRESHED); + + // Invalidate course enabled data for the handlers that are enabled at site level. + if (courseId) { + // Invalidate only options for this course. + promises.push(this.coursesProvider.invalidateCoursesOptions([courseId])); + promises.push(this.invalidateCourseHandlers(courseId)); + } else { + // Invalidate all options. + promises.push(this.coursesProvider.invalidateUserNavigationOptions()); + promises.push(this.coursesProvider.invalidateUserAdministrationOptions()); + + for (let cId in this.coursesHandlers) { + promises.push(this.invalidateCourseHandlers(parseInt(cId, 10))); + } + } + + this.clearCoursesHandlers(courseId); + + return Promise.all(promises); + } + + /** + * Get the handlers for a course using a certain access type. + * + * @param {number} courseId The course ID. + * @param {boolean} refresh True if it should refresh the list. + * @param {any} accessData Access type and data. Default, guest, ... + * @param {any} [navOptions] Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} [admOptions] Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {Promise} Promise resolved with array of handlers. + */ + protected getHandlersForAccess(courseId: number, refresh: boolean, accessData: any, navOptions?: any, + admOptions?: any) : Promise { + + // If the handlers aren't loaded, do not refresh. + if (!this.loaded[courseId]) { + refresh = false; + } + + if (refresh || !this.coursesHandlers[courseId] || this.coursesHandlers[courseId].access.type != accessData.type) { + if (!this.coursesHandlers[courseId]) { + this.coursesHandlers[courseId] = {}; + } + this.coursesHandlers[courseId].access = accessData; + this.coursesHandlers[courseId].navOptions = navOptions; + this.coursesHandlers[courseId].admOptions = admOptions; + this.coursesHandlers[courseId].deferred = this.utils.promiseDefer(); + this.updateHandlersForCourse(courseId, accessData, navOptions, admOptions); + } + + return this.coursesHandlers[courseId].deferred.promise.then(() => { + return this.coursesHandlers[courseId].enabledHandlers; + }); + } + + /** + * Get the list of handlers that should be displayed for a course. + * This function should be called only when the handlers need to be displayed, since it can call several WebServices. + * + * @param {any} course The course object. + * @param {boolean} [refresh] True if it should refresh the list. + * @param {boolean} [isGuest] Whether it's guest. + * @param {any} [navOptions] Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} [admOptions] Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {Promise} Promise resolved with array of handlers. + */ + getHandlersToDisplay(course: any, refresh?: boolean, isGuest?: boolean, navOptions?: any, admOptions?: any) : + Promise { + course.id = parseInt(course.id, 10); + + let accessData = { + type: isGuest ? CoreCoursesProvider.ACCESS_GUEST : CoreCoursesProvider.ACCESS_DEFAULT + }; + + if (navOptions) { + course.navOptions = navOptions; + } + if (admOptions) { + course.admOptions = admOptions; + } + + return this.loadCourseOptions(course, refresh).then(() => { + // Call getHandlersForAccess to make sure the handlers have been loaded. + return this.getHandlersForAccess(course.id, refresh, accessData, course.navOptions, course.admOptions); + }).then(() => { + let handlersToDisplay: CoreCoursesHandlerToDisplay[] = [], + promises = [], + promise; + + this.coursesHandlers[course.id].enabledHandlers.forEach((handler) => { + if (handler.shouldDisplayForCourse) { + promise = Promise.resolve(handler.shouldDisplayForCourse( + course.id, accessData, course.navOptions, course.admOptions)); + } else { + // Not implemented, assume it should be displayed. + promise = Promise.resolve(true); + } + + promises.push(promise.then((enabled) => { + if (enabled) { + handlersToDisplay.push({ + data: handler.getDisplayData(course), + priority: handler.priority, + prefetch: handler.prefetch + }); + } + })); + }); + + return this.utils.allPromises(promises).then(() => { + // Sort them by priority. + handlersToDisplay.sort((a, b) => { + return b.priority - a.priority; + }); + + return handlersToDisplay; + }); + }); + } + + /** + * Check if a course has any handler enabled for default access, using course object. + * + * @param {any} course The course object. + * @param {boolean} [refresh] True if it should refresh the list. + * @return {Promise} Promise resolved with boolean: true if it has handlers, false otherwise. + */ + hasHandlersForCourse(course: any, refresh?: boolean) : Promise { + // Load course options if missing. + return this.loadCourseOptions(course, refresh).then(() => { + return this.hasHandlersForDefault(course.id, refresh, course.navOptions, course.admOptions); + }); + } + + /** + * Check if a course has any handler enabled for default access. + * + * @param {number} courseId The course ID. + * @param {boolean} [refresh] True if it should refresh the list. + * @param {any} [navOptions] Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} [admOptions] Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {Promise} Promise resolved with boolean: true if it has handlers, false otherwise. + */ + hasHandlersForDefault(courseId: number, refresh?: boolean, navOptions?: any, admOptions?: any) : Promise { + // Default access. + let accessData = { + type: CoreCoursesProvider.ACCESS_DEFAULT + }; + return this.getHandlersForAccess(courseId, refresh, accessData, navOptions, admOptions).then((handlers) => { + return !!(handlers && handlers.length); + }); + } + + /** + * Check if a course has any handler enabled for guest access. + * + * @param {number} courseId The course ID. + * @param {boolean} [refresh] True if it should refresh the list. + * @param {any} [navOptions] Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} [admOptions] Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {Promise} Promise resolved with boolean: true if it has handlers, false otherwise. + */ + hasHandlersForGuest(courseId: number, refresh?: boolean, navOptions?: any, admOptions?: any) : Promise { + // Guest access. + var accessData = { + type: CoreCoursesProvider.ACCESS_GUEST + }; + return this.getHandlersForAccess(courseId, refresh, accessData, navOptions, admOptions).then((handlers) => { + return !!(handlers && handlers.length); + }); + } + + /** + * Invalidate the data to be able to determine if handlers are enabled for a certain course. + * + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved when done. + */ + invalidateCourseHandlers(courseId: number) : Promise { + let promises = [], + courseData = this.coursesHandlers[courseId]; + + if (!courseData) { + return Promise.resolve(); + } + + courseData.enabledHandlers.forEach((handler) => { + if (handler && handler.invalidateEnabledForCourse) { + promises.push(Promise.resolve( + handler.invalidateEnabledForCourse(courseId, courseData.navOptions, courseData.admOptions))); + } + }); + + return this.utils.allPromises(promises); + } + + /** + * Check if a time belongs to the last update handlers call. + * This is to handle the cases where updateHandlers don't finish in the same order as they're called. + * + * @param {number} time Time to check. + * @return {boolean} Whether it's the last call. + */ + isLastUpdateCall(time: number) : boolean { + if (!this.lastUpdateHandlersStart) { + return true; + } + return time == this.lastUpdateHandlersStart; + } + + /** + * Check if a time belongs to the last update handlers for course call. + * This is to handle the cases where updateHandlersForCourse don't finish in the same order as they're called. + * + * @param {number} courseId Course ID. + * @param {number} time Time to check. + * @return {boolean} Whether it's the last call. + */ + isLastUpdateCourseCall(courseId: number, time: number) : boolean { + if (!this.lastUpdateHandlersForCoursesStart[courseId]) { + return true; + } + return time == this.lastUpdateHandlersForCoursesStart[courseId]; + } + + /** + * Load course options if missing. + * + * @param {any} course The course object. + * @param {boolean} [refresh] True if it should refresh the list. + * @return {Promise} Promise resolved when done. + */ + protected loadCourseOptions(course: any, refresh?: boolean) : Promise { + if (typeof course.navOptions == 'undefined' || typeof course.admOptions == 'undefined' || refresh) { + return this.coursesProvider.getCoursesOptions([course.id]).then((options) => { + course.navOptions = options.navOptions[course.id]; + course.admOptions = options.admOptions[course.id]; + }); + } else { + return Promise.resolve(); + } + } + + /** + * Register a handler. + * + * @param {CoreCoursesHandler} handler The handler to register. + * @return {boolean} True if registered successfully, false otherwise. + */ + registerHandler(handler: CoreCoursesHandler) : boolean { + if (typeof this.handlers[handler.name] !== 'undefined') { + this.logger.log(`Addon '${handler.name}' already registered`); + return false; + } + this.logger.log(`Registered addon '${handler.name}'`); + this.handlers[handler.name] = handler; + return true; + } + + /** + * Update the handler for the current site. + * + * @param {CoreInitHandler} handler The handler to check. + * @param {number} time Time this update process started. + * @return {Promise} Resolved when done. + */ + protected updateHandler(handler: CoreCoursesHandler, time: number) : Promise { + let promise, + siteId = this.sitesProvider.getCurrentSiteId(), + currentSite = this.sitesProvider.getCurrentSite(); + + if (!this.sitesProvider.isLoggedIn()) { + promise = Promise.reject(null); + } else if (currentSite.isFeatureDisabled('$mmCoursesDelegate_' + handler.name)) { + promise = Promise.resolve(false); + } else { + promise = Promise.resolve(handler.isEnabled()); + } + + // Checks if the handler is enabled. + return promise.catch(() => { + return false; + }).then((enabled: boolean) => { + // Verify that this call is the last one that was started. + // Check that site hasn't changed since the check started. + if (this.isLastUpdateCall(time) && this.sitesProvider.getCurrentSiteId() === siteId) { + if (enabled) { + this.enabledHandlers[handler.name] = handler; + } else { + delete this.enabledHandlers[handler.name]; + } + } + }); + } + + /** + * Update the handlers for the current site. + * + * @return {Promise} Resolved when done. + */ + protected updateHandlers() : Promise { + let promises = [], + siteId = this.sitesProvider.getCurrentSiteId(), + now = Date.now(); + + this.logger.debug('Updating handlers for current site.'); + + this.lastUpdateHandlersStart = now; + + // Loop over all the handlers. + for (let name in this.handlers) { + promises.push(this.updateHandler(this.handlers[name], now)); + } + + return Promise.all(promises).then(() => { + return true; + }, () => { + // Never reject. + return true; + }).then(() => { + // Verify that this call is the last one that was started. + if (this.isLastUpdateCall(now) && this.sitesProvider.getCurrentSiteId() === siteId) { + // Update handlers for all courses. + for (let courseId in this.coursesHandlers) { + let handler = this.coursesHandlers[courseId]; + this.updateHandlersForCourse(parseInt(courseId, 10), handler.access, handler.navOptions, handler.admOptions); + } + } + }); + } + + /** + * Update the handlers for a certain course. + * + * @param {number} courseId The course ID. + * @param {any} accessData Access type and data. Default, guest, ... + * @param {any} [navOptions] Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} [admOptions] Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {Promise} Resolved when updated. + * @protected + */ + updateHandlersForCourse(courseId: number, accessData: any, navOptions?: any, admOptions?: any) : Promise { + let promises = [], + enabledForCourse = [], + siteId = this.sitesProvider.getCurrentSiteId(), + now = Date.now(); + + this.lastUpdateHandlersForCoursesStart[courseId] = now; + + for (let name in this.enabledHandlers) { + let handler = this.enabledHandlers[name]; + + // Checks if the handler is enabled for the user. + promises.push(Promise.resolve(handler.isEnabledForCourse(courseId, accessData, navOptions, admOptions)) + .then(function(enabled) { + if (enabled) { + enabledForCourse.push(handler); + } else { + return Promise.reject(null); + } + }).catch(() => { + // Nothing to do here, it is not enabled for this user. + })); + } + + return Promise.all(promises).then(() => { + return true; + }).catch(() => { + // Never fails. + return true; + }).finally(() => { + // Verify that this call is the last one that was started. + // Check that site hasn't changed since the check started. + if (this.isLastUpdateCourseCall(courseId, now) && this.sitesProvider.getCurrentSiteId() === siteId) { + // Update the coursesHandlers array with the new enabled addons. + this.coursesHandlers[courseId].enabledHandlers = enabledForCourse; + this.loaded[courseId] = true; + + // Resolve the promise. + this.coursesHandlers[courseId].deferred.resolve(); + } + }); + }; +} diff --git a/src/core/mainmenu/providers/delegate.ts b/src/core/mainmenu/providers/delegate.ts index 613d562be..5872e45f0 100644 --- a/src/core/mainmenu/providers/delegate.ts +++ b/src/core/mainmenu/providers/delegate.ts @@ -102,10 +102,10 @@ export class CoreMainMenuDelegate { */ registerHandler(handler: CoreMainMenuHandler) : boolean { if (typeof this.handlers[handler.name] !== 'undefined') { - this.logger.log(`Addon 'handler.name' already registered`); + this.logger.log(`Addon '${handler.name}' already registered`); return false; } - this.logger.log(`Registered addon 'handler.name'`); + this.logger.log(`Registered addon '${handler.name}'`); this.handlers[handler.name] = handler; return true; } diff --git a/src/providers/events.ts b/src/providers/events.ts index ee551eeb5..27aeb835a 100644 --- a/src/providers/events.ts +++ b/src/providers/events.ts @@ -98,9 +98,9 @@ export class CoreEventsProvider { * Triggers an event, notifying all the observers. * * @param {string} event Name of the event to trigger. - * @param {any} data Data to pass to the observers. + * @param {any} [data] Data to pass to the observers. */ - trigger(eventName: string, data: any) : void { + trigger(eventName: string, data?: any) : void { this.logger.debug(`Event '${eventName}' triggered.`); if (this.observables[eventName]) { this.observables[eventName].next(data); diff --git a/src/providers/plugin-file-delegate.ts b/src/providers/plugin-file-delegate.ts index e685374e5..71af4e9a7 100644 --- a/src/providers/plugin-file-delegate.ts +++ b/src/providers/plugin-file-delegate.ts @@ -68,10 +68,10 @@ export class CorePluginFileDelegate { */ registerHandler(handler: CorePluginFileHandler) : boolean { if (typeof this.handlers[handler.name] !== 'undefined') { - this.logger.log(`Addon 'handler.name' already registered`); + this.logger.log(`Addon '${handler.name}' already registered`); return false; } - this.logger.log(`Registered addon 'handler.name'`); + this.logger.log(`Registered addon '${handler.name}'`); this.handlers[handler.name] = handler; return true; } From e6e17ae56de33395ac1d734df7816ed448b99355 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 22 Dec 2017 08:06:01 +0100 Subject: [PATCH 17/24] MOBILE-2302 core: Implement context menu --- src/app/app.module.ts | 7 +- src/components/components.module.ts | 15 ++- .../context-menu/context-menu-item.ts | 114 ++++++++++++++++++ .../context-menu/context-menu-popover.html | 10 ++ .../context-menu/context-menu-popover.ts | 69 +++++++++++ src/components/context-menu/context-menu.html | 4 + src/components/context-menu/context-menu.ts | 98 +++++++++++++++ .../courses/pages/my-courses/my-courses.html | 4 +- .../pages/my-overview/my-overview.html | 6 +- src/lang/en.json | 2 + 10 files changed, 323 insertions(+), 6 deletions(-) create mode 100644 src/components/context-menu/context-menu-item.ts create mode 100644 src/components/context-menu/context-menu-popover.html create mode 100644 src/components/context-menu/context-menu-popover.ts create mode 100644 src/components/context-menu/context-menu.html create mode 100644 src/components/context-menu/context-menu.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7bd5875e5..6b6d5c152 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -31,7 +31,6 @@ import { CoreLoggerProvider } from '../providers/logger'; import { CoreDbProvider } from '../providers/db'; import { CoreAppProvider } from '../providers/app'; import { CoreConfigProvider } from '../providers/config'; -import { CoreEmulatorModule } from '../core/emulator/emulator.module'; import { CoreLangProvider } from '../providers/lang'; import { CoreTextUtilsProvider } from '../providers/utils/text'; import { CoreDomUtilsProvider } from '../providers/utils/dom'; @@ -53,10 +52,13 @@ import { CoreFilepoolProvider } from '../providers/filepool'; import { CoreUpdateManagerProvider } from '../providers/update-manager'; import { CorePluginFileDelegate } from '../providers/plugin-file-delegate'; +import { CoreComponentsModule } from '../components/components.module'; +import { CoreEmulatorModule } from '../core/emulator/emulator.module'; import { CoreLoginModule } from '../core/login/login.module'; import { CoreMainMenuModule } from '../core/mainmenu/mainmenu.module'; import { CoreCoursesModule } from '../core/courses/courses.module'; + // For translate loader. AoT requires an exported function for factories. export function createTranslateLoader(http: HttpClient) { return new TranslateHttpLoader(http, './assets/lang/', '.json'); @@ -83,7 +85,8 @@ export function createTranslateLoader(http: HttpClient) { CoreEmulatorModule, CoreLoginModule, CoreMainMenuModule, - CoreCoursesModule + CoreCoursesModule, + CoreComponentsModule ], bootstrap: [IonicApp], entryComponents: [ diff --git a/src/components/components.module.ts b/src/components/components.module.ts index 2b462dd4d..8be44288c 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -25,6 +25,9 @@ import { CoreProgressBarComponent } from './progress-bar/progress-bar'; import { CoreEmptyBoxComponent } from './empty-box/empty-box'; import { CoreSearchBoxComponent } from './search-box/search-box'; import { CoreFileComponent } from './file/file'; +import { CoreContextMenuComponent } from './context-menu/context-menu'; +import { CoreContextMenuItemComponent } from './context-menu/context-menu-item'; +import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-popover'; @NgModule({ declarations: [ @@ -36,7 +39,13 @@ import { CoreFileComponent } from './file/file'; CoreProgressBarComponent, CoreEmptyBoxComponent, CoreSearchBoxComponent, - CoreFileComponent + CoreFileComponent, + CoreContextMenuComponent, + CoreContextMenuItemComponent, + CoreContextMenuPopoverComponent + ], + entryComponents: [ + CoreContextMenuPopoverComponent ], imports: [ IonicModule, @@ -52,7 +61,9 @@ import { CoreFileComponent } from './file/file'; CoreProgressBarComponent, CoreEmptyBoxComponent, CoreSearchBoxComponent, - CoreFileComponent + CoreFileComponent, + CoreContextMenuComponent, + CoreContextMenuItemComponent ] }) export class CoreComponentsModule {} diff --git a/src/components/context-menu/context-menu-item.ts b/src/components/context-menu/context-menu-item.ts new file mode 100644 index 000000000..61ab66d29 --- /dev/null +++ b/src/components/context-menu/context-menu-item.ts @@ -0,0 +1,114 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Input, Output, OnInit, OnDestroy, EventEmitter, OnChanges, SimpleChange } from '@angular/core'; +import { CoreContextMenuComponent } from './context-menu'; + + +/** + * This directive adds a item to the Context Menu popover. + * + * @description + * This directive defines and item to be added to the popover generated in CoreContextMenu. + * + * It is required to place this tag inside a core-context-menu tag. + * + * + * + * + */ +@Component({ + selector: 'core-context-menu-item', + template: '' +}) +export class CoreContextMenuItemComponent implements OnInit, OnDestroy, OnChanges { + @Input() content: string; // Content of the item. + @Input() iconDescription?: string; // Name of the icon to be shown on the left side of the item. + @Input() iconAction?: string; // Name of the icon to be shown on the right side of the item. It represents the action to do on + // click. If is "spinner" an spinner will be shown. If no icon or spinner is selected, no action + // or link will work. If href but no iconAction is provided ion-arrow-right-c will be used. + @Input() ariaDescription?: string; // Aria label to add to iconDescription. + @Input() ariaAction?: string; // Aria label to add to iconAction. If not set, it will be equal to content. + @Input() href?: string; // Link to go if no action provided. + @Input() captureLink?: boolean|string; // Whether the link needs to be captured by the app. + @Input() autoLogin?: string; // Whether the link needs to be opened using auto-login. + @Input() closeOnClick?: boolean|string = true; // Whether to close the popover when the item is clicked. + @Input() priority?: number; // Used to sort items. The highest priority, the highest position. + @Input() badge?: string; // A badge to show in the item. + @Input() badgeClass?: number; // A class to set in the badge. + @Input() hidden?: boolean; // Whether the item should be hidden. + @Output() action?: EventEmitter; // Will emit an event when the item clicked. + + protected hasAction = false; + protected destroyed = false; + + constructor(private ctxtMenu: CoreContextMenuComponent) { + this.action = new EventEmitter(); + } + + /** + * Component being initialized. + */ + ngOnInit() { + // Initialize values. + this.priority = this.priority || 1; + this.closeOnClick = this.getBooleanValue(this.closeOnClick, true); + this.hasAction = this.action.observers.length > 0; + this.ariaAction = this.ariaAction || this.content; + + if (this.hasAction) { + this.href = ''; + } + + // Navigation help if href provided. + this.captureLink = this.href && this.captureLink ? this.captureLink : false; + this.autoLogin = this.autoLogin || 'check'; + + if (!this.destroyed) { + this.ctxtMenu.addItem(this); + } + } + + /** + * Get a boolean value from item. + * + * @param {any} value Value to check. + * @param {boolean} defaultValue Value to use if undefined. + * @return {boolean} Boolean value. + */ + protected getBooleanValue(value: any, defaultValue: boolean) : boolean { + if (typeof value == 'undefined') { + return defaultValue; + } + return value && value !== 'false'; + } + + /** + * Component destroyed. + */ + ngOnDestroy() { + this.destroyed = true; + this.ctxtMenu.removeItem(this); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}) { + if (changes.hidden && !changes.hidden.firstChange) { + this.ctxtMenu.itemsChanged(); + } + } +} diff --git a/src/components/context-menu/context-menu-popover.html b/src/components/context-menu/context-menu-popover.html new file mode 100644 index 000000000..145ee534c --- /dev/null +++ b/src/components/context-menu/context-menu-popover.html @@ -0,0 +1,10 @@ + + {{title}} + + + + + + {{item.badge}} + + \ No newline at end of file diff --git a/src/components/context-menu/context-menu-popover.ts b/src/components/context-menu/context-menu-popover.ts new file mode 100644 index 000000000..fa144228e --- /dev/null +++ b/src/components/context-menu/context-menu-popover.ts @@ -0,0 +1,69 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NavParams, ViewController } from 'ionic-angular'; +import { CoreContextMenuItemComponent } from './context-menu-item'; + +/** + * Component to display a list of items received by param in a popover. + */ +@Component({ + selector: 'core-context-menu-popover', + templateUrl: 'context-menu-popover.html' +}) +export class CoreContextMenuPopoverComponent { + title: string; + items: CoreContextMenuItemComponent[]; + + constructor(navParams: NavParams, private viewCtrl: ViewController) { + this.title = navParams.get('title'); + this.items = navParams.get('items') || []; + } + + /** + * Close the popover. + */ + closeMenu() : void { + this.viewCtrl.dismiss(); + } + + /** + * Function called when an item is clicked. + * + * @param {Event} event Click event. + * @param {CoreContextMenuItemComponent} item Item clicked. + * @return {boolean} Return true if success, false if error. + */ + itemClicked(event: Event, item: CoreContextMenuItemComponent) : boolean { + if (item.action.observers.length > 0) { + event.preventDefault(); + event.stopPropagation(); + + if (!item.iconAction || item.iconAction == 'spinner') { + return false; + } + + if (item.closeOnClick) { + this.closeMenu(); + } + + item.action.emit(this.closeMenu.bind(this)); + } else if (item.href && item.closeOnClick) { + this.closeMenu(); + } + + return true; + } +} diff --git a/src/components/context-menu/context-menu.html b/src/components/context-menu/context-menu.html new file mode 100644 index 000000000..94f724860 --- /dev/null +++ b/src/components/context-menu/context-menu.html @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/src/components/context-menu/context-menu.ts b/src/components/context-menu/context-menu.ts new file mode 100644 index 000000000..43ce298cc --- /dev/null +++ b/src/components/context-menu/context-menu.ts @@ -0,0 +1,98 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Input, OnInit } from '@angular/core'; +import { PopoverController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreContextMenuItemComponent } from './context-menu-item'; +import { CoreContextMenuPopoverComponent } from './context-menu-popover'; +import { Subject } from 'rxjs'; + +/** + * This component adds a button (usually in the navigation bar) that displays a context menu popover. + */ +@Component({ + selector: 'core-context-menu', + templateUrl: 'context-menu.html' +}) +export class CoreContextMenuComponent implements OnInit { + @Input() icon?: string; // Icon to be shown on the navigation bar. Default: Kebab menu icon. + @Input() title?: string; // Aria label and text to be shown on the top of the popover. + + hideMenu: boolean; + ariaLabel: string; + protected items: CoreContextMenuItemComponent[] = []; + protected itemsChangedStream: Subject; // Stream to update the hideMenu boolean when items change. + + constructor(private translate: TranslateService, private popoverCtrl: PopoverController) { + // Create the stream and subscribe to it. We ignore successive changes during 250ms. + this.itemsChangedStream = new Subject(); + this.itemsChangedStream.auditTime(250).subscribe(() => { + // Hide the menu if all items are hidden. + this.hideMenu = !this.items.some((item) => { + return !item.hidden; + }); + }) + } + + /** + * Component being initialized. + */ + ngOnInit() { + this.icon = this.icon || 'more'; + this.ariaLabel = this.title || this.translate.instant('core.info'); + } + + /** + * Add a context menu item. + * + * @param {CoreContextMenuItemComponent} item The item to add. + */ + addItem(item: CoreContextMenuItemComponent) : void { + this.items.push(item); + this.itemsChanged(); + } + + /** + * Function called when the items change. + */ + itemsChanged() { + this.itemsChangedStream.next(); + } + + /** + * Remove an item from the context menu. + * + * @param {CoreContextMenuItemComponent} item The item to remove. + */ + removeItem(item: CoreContextMenuItemComponent) : void { + let index = this.items.indexOf(item); + if (index >= 0) { + this.items.splice(index, 1); + } + this.itemsChanged(); + } + + /** + * Show the context menu. + * + * @param {MouseEvent} event Event. + */ + showContextMenu(event: MouseEvent) : void { + let popover = this.popoverCtrl.create(CoreContextMenuPopoverComponent, {title: this.title, items: this.items}); + popover.present({ + ev: event + }); + } +} diff --git a/src/core/courses/pages/my-courses/my-courses.html b/src/core/courses/pages/my-courses/my-courses.html index 720c27686..7a2eed8d4 100644 --- a/src/core/courses/pages/my-courses/my-courses.html +++ b/src/core/courses/pages/my-courses/my-courses.html @@ -6,7 +6,9 @@ - + + + diff --git a/src/core/courses/pages/my-overview/my-overview.html b/src/core/courses/pages/my-overview/my-overview.html index 1d9e3a1aa..b45f3575f 100644 --- a/src/core/courses/pages/my-overview/my-overview.html +++ b/src/core/courses/pages/my-overview/my-overview.html @@ -49,7 +49,11 @@
- + + + + +
diff --git a/src/lang/en.json b/src/lang/en.json index 64a2104f4..32ec7dfd3 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -99,6 +99,8 @@ "lastdownloaded": "Last downloaded", "lastmodified": "Last modified", "lastsync": "Last synchronization", + "layoutgrid": "Grid", + "list": "List", "listsep": ",", "loading": "Loading", "loadmore": "Load more", From 02622db2d01e02e0290ecb889e9577e6c14174d5 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 4 Jan 2018 10:08:28 +0100 Subject: [PATCH 18/24] MOBILE-2302 myoverview: Remove roundProgress and move filter option --- .../course-progress/course-progress.html | 24 +++---------------- .../course-progress/course-progress.ts | 3 --- .../pages/my-overview/my-overview.html | 14 +++++------ .../courses/pages/my-overview/my-overview.ts | 1 - 4 files changed, 10 insertions(+), 32 deletions(-) diff --git a/src/core/courses/components/course-progress/course-progress.html b/src/core/courses/components/course-progress/course-progress.html index fa01a2fe5..b9cc76d57 100644 --- a/src/core/courses/components/course-progress/course-progress.html +++ b/src/core/courses/components/course-progress/course-progress.html @@ -1,26 +1,8 @@ - -
-
-
{{course.progress}}%
-
- - - {{course.progress}}% - - - -
-
-
- -
-
+

- - + + diff --git a/src/core/courses/components/course-progress/course-progress.ts b/src/core/courses/components/course-progress/course-progress.ts index 54b5771eb..a3dd08c3c 100644 --- a/src/core/courses/components/course-progress/course-progress.ts +++ b/src/core/courses/components/course-progress/course-progress.ts @@ -31,7 +31,6 @@ import { CoreUtilsProvider } from '../../../../providers/utils/utils'; }) export class CoreCoursesCourseProgressComponent implements OnInit { @Input() course: any; // The course to render. - @Input() roundProgress?: boolean|string; // Whether to show the progress. @Input() showSummary?: boolean|string; // Whether to show the summary. actionsLoaded = true; @@ -57,8 +56,6 @@ export class CoreCoursesCourseProgressComponent implements OnInit { */ ngOnInit() { // @todo: Handle course prefetch. - // @todo: Handle course handlers (participants, etc.). - this.roundProgress = this.utils.isTrueOrOne(this.roundProgress); this.showSummary = this.utils.isTrueOrOne(this.showSummary); } diff --git a/src/core/courses/pages/my-overview/my-overview.html b/src/core/courses/pages/my-overview/my-overview.html index b45f3575f..d7be21e0e 100644 --- a/src/core/courses/pages/my-overview/my-overview.html +++ b/src/core/courses/pages/my-overview/my-overview.html @@ -6,6 +6,9 @@ + @@ -14,6 +17,7 @@ +
{{ 'core.courses.timeline' | translate }} {{ 'core.courses.courses' | translate }} @@ -31,7 +35,7 @@
- +
@@ -49,11 +53,7 @@ - - - - - +
@@ -67,7 +67,7 @@
- +
diff --git a/src/core/courses/pages/my-overview/my-overview.ts b/src/core/courses/pages/my-overview/my-overview.ts index a1022b51d..ce7d535b2 100644 --- a/src/core/courses/pages/my-overview/my-overview.ts +++ b/src/core/courses/pages/my-overview/my-overview.ts @@ -166,7 +166,6 @@ export class CoreCoursesMyOverviewPage { // Load course options of the course. return this.coursesProvider.getCoursesOptions(courseIds).then((options) => { courses.forEach((course) => { - course.showProgress = true; course.progress = isNaN(parseInt(course.progress, 10)) ? false : parseInt(course.progress, 10); course.navOptions = options.navOptions[course.id]; From 730631e153b8c1b0eebc1a86fdeaaed347980ddc Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 8 Jan 2018 13:31:09 +0100 Subject: [PATCH 19/24] MOBILE-2302 courses: Improve course progress treatment --- src/components/progress-bar/progress-bar.ts | 14 +++++++++++--- .../course-progress/course-progress.html | 2 +- src/core/courses/pages/my-courses/my-courses.ts | 1 - src/core/courses/pages/my-overview/my-overview.ts | 2 -- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/components/progress-bar/progress-bar.ts b/src/components/progress-bar/progress-bar.ts index a77e70291..a7b6df126 100644 --- a/src/components/progress-bar/progress-bar.ts +++ b/src/components/progress-bar/progress-bar.ts @@ -45,15 +45,23 @@ export class CoreProgressBarComponent implements OnChanges { if (changes.progress) { // Progress has changed. - this.width = this.sanitizer.bypassSecurityTrustStyle(this.progress + '%'); if (typeof this.progress == 'string') { this.progress = parseInt(this.progress, 10); } if (this.progress < 0 || isNaN(this.progress)) { this.progress = -1; - } else if (!this.textSupplied) { - this.text = String(this.progress); + } + + if (this.progress != -1) { + // Remove decimals. + this.progress = Math.floor(this.progress); + + if (!this.textSupplied) { + this.text = String(this.progress); + } + + this.width = this.sanitizer.bypassSecurityTrustStyle(this.progress + '%'); } } } diff --git a/src/core/courses/components/course-progress/course-progress.html b/src/core/courses/components/course-progress/course-progress.html index b9cc76d57..e66ef5389 100644 --- a/src/core/courses/components/course-progress/course-progress.html +++ b/src/core/courses/components/course-progress/course-progress.html @@ -1,7 +1,7 @@

- + diff --git a/src/core/courses/pages/my-courses/my-courses.ts b/src/core/courses/pages/my-courses/my-courses.ts index 54ccbb318..ea3941f05 100644 --- a/src/core/courses/pages/my-courses/my-courses.ts +++ b/src/core/courses/pages/my-courses/my-courses.ts @@ -78,7 +78,6 @@ export class CoreCoursesMyCoursesPage implements OnDestroy { return this.coursesProvider.getCoursesOptions(courseIds).then((options) => { courses.forEach((course) => { - course.progress = isNaN(parseInt(course.progress, 10)) ? false : parseInt(course.progress, 10); course.navOptions = options.navOptions[course.id]; course.admOptions = options.admOptions[course.id]; }); diff --git a/src/core/courses/pages/my-overview/my-overview.ts b/src/core/courses/pages/my-overview/my-overview.ts index ce7d535b2..f3643851c 100644 --- a/src/core/courses/pages/my-overview/my-overview.ts +++ b/src/core/courses/pages/my-overview/my-overview.ts @@ -166,8 +166,6 @@ export class CoreCoursesMyOverviewPage { // Load course options of the course. return this.coursesProvider.getCoursesOptions(courseIds).then((options) => { courses.forEach((course) => { - course.progress = isNaN(parseInt(course.progress, 10)) ? false : parseInt(course.progress, 10); - course.navOptions = options.navOptions[course.id]; course.admOptions = options.admOptions[course.id]; }); From ad5365edfea582da681e106ae1f6d9274ee932b3 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 8 Jan 2018 13:21:15 +0100 Subject: [PATCH 20/24] MOBILE-2302 core: Implement redirect to another page/site --- src/core/login/providers/helper.ts | 71 ++++++++++++++++++++++++++ src/core/mainmenu/pages/menu/menu.html | 3 +- src/core/mainmenu/pages/menu/menu.ts | 41 +++++++++++---- 3 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/core/login/providers/helper.ts b/src/core/login/providers/helper.ts index 8b6314010..571319de1 100644 --- a/src/core/login/providers/helper.ts +++ b/src/core/login/providers/helper.ts @@ -615,6 +615,42 @@ export class CoreLoginHelperProvider { return code == CoreConstants.loginSSOCode || code == CoreConstants.loginSSOInAppCode; } + /** + * Load a site and load a certain page in that site. + * + * @param {string} page Name of the page to load. + * @param {any} params Params to pass to the page. + * @param {string} siteId Site to load. + */ + protected loadSiteAndPage(page: string, params: any, siteId: string) : void { + if (siteId == CoreConstants.noSiteId) { + // Page doesn't belong to a site, just load the page. + this.navCtrl.setRoot(page, params); + } else { + let modal = this.domUtils.showModalLoading(); + this.sitesProvider.loadSite(siteId).then(() => { + if (!this.isSiteLoggedOut(page, params)) { + this.loadPageInMainMenu(page, params); + } + }).catch(() => { + // Site doesn't exist. + this.navCtrl.setRoot('CoreLoginSitesPage') + }).finally(() => { + modal.dismiss(); + }); + } + } + + /** + * Load a certain page in the main menu page. + * + * @param {string} page Name of the page to load. + * @param {any} params Params to pass to the page. + */ + protected loadPageInMainMenu(page: string, params: any) : void { + this.navCtrl.setRoot('CoreMainMenuPage', {redirectPage: page, redirectParams: params}) + } + /** * Open a browser to perform OAuth login (Google, Facebook, Microsoft). * @@ -773,6 +809,41 @@ export class CoreLoginHelperProvider { return loginUrl; } + /** + * Redirect to a new page, setting it as the root page and loading the right site if needed. + * + * @param {string} page Name of the page to load. + * @param {any} params Params to pass to the page. + * @param {string} [siteId] Site to load. If not defined, current site. + */ + redirect(page: string, params?: any, siteId?: string) : void { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (this.sitesProvider.isLoggedIn()) { + if (siteId && siteId != this.sitesProvider.getCurrentSiteId()) { + // Target page belongs to a different site. Change site. + // @todo Once we have addon manager. + // if ($mmAddonManager.hasRemoteAddonsLoaded()) { + // // The site has remote addons so the app will be restarted. Store the data and logout. + // this.appProvider.storeRedirect(siteId, page, params); + // this.sitesProvider.logout(); + // } else { + this.sitesProvider.logout().then(() => { + this.loadSiteAndPage(page, params, siteId); + }); + // } + } else { + this.loadPageInMainMenu(page, params); + } + } else { + if (siteId) { + this.loadSiteAndPage(page, params, siteId); + } else { + this.navCtrl.setRoot('CoreLoginSitesPage') + } + } + } + /** * Request a password reset. * diff --git a/src/core/mainmenu/pages/menu/menu.html b/src/core/mainmenu/pages/menu/menu.html index ec3f330e1..4e56c064d 100644 --- a/src/core/mainmenu/pages/menu/menu.html +++ b/src/core/mainmenu/pages/menu/menu.html @@ -1,3 +1,4 @@ - + + \ No newline at end of file diff --git a/src/core/mainmenu/pages/menu/menu.ts b/src/core/mainmenu/pages/menu/menu.ts index 57932bbaa..3e0091f55 100644 --- a/src/core/mainmenu/pages/menu/menu.ts +++ b/src/core/mainmenu/pages/menu/menu.ts @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnDestroy } from '@angular/core'; -import { IonicPage, NavController } from 'ionic-angular'; +import { Component, OnDestroy, ViewChild } from '@angular/core'; +import { IonicPage, NavController, NavParams, Tabs } from 'ionic-angular'; import { CoreEventsProvider } from '../../../../providers/events'; import { CoreSitesProvider } from '../../../../providers/sites'; import { CoreMainMenuProvider } from '../../providers/mainmenu'; @@ -28,8 +28,33 @@ import { CoreMainMenuDelegate, CoreMainMenuHandlerData } from '../../providers/d templateUrl: 'menu.html', }) export class CoreMainMenuPage implements OnDestroy { + // Use a setter to wait for ion-tabs to be loaded because it's inside a ngIf. + @ViewChild('mainTabs') set mainTabs(ionTabs: Tabs) { + if (ionTabs && this.redirectPage && !this.redirectPageLoaded) { + // Tabs ready and there is a redirect page set. Load it. + this.redirectPageLoaded = true; + + // Check if the page is the root page of any of the tabs. + let indexToSelect = 0; + for (let i = 0; i < this.tabs.length; i++) { + if (this.tabs[i].page == this.redirectPage) { + indexToSelect = i + 1; + break; + } + } + + // Use a setTimeout, otherwise loading the first tab opens a new state for some reason. + setTimeout(() => { + ionTabs.select(indexToSelect); + }); + } + }; + tabs: CoreMainMenuHandlerData[] = []; loaded: boolean; + redirectPage: string; + redirectParams: any; + protected subscription; protected moreTabData = { page: 'CoreMainMenuMorePage', @@ -37,15 +62,12 @@ export class CoreMainMenuPage implements OnDestroy { icon: 'more' }; protected moreTabAdded = false; - protected logoutObserver; + protected redirectPageLoaded = false; - constructor(private menuDelegate: CoreMainMenuDelegate, private sitesProvider: CoreSitesProvider, + constructor(private menuDelegate: CoreMainMenuDelegate, private sitesProvider: CoreSitesProvider, navParams: NavParams, private navCtrl: NavController, eventsProvider: CoreEventsProvider) { - - // Go to sites page when user is logged out. - this.logoutObserver = eventsProvider.on(CoreEventsProvider.LOGOUT, () => { - this.navCtrl.setRoot('CoreLoginSitesPage'); - }); + this.redirectPage = navParams.get('redirectPage'); + this.redirectParams = navParams.get('redirectParams'); } /** @@ -93,6 +115,5 @@ export class CoreMainMenuPage implements OnDestroy { */ ngOnDestroy() { this.subscription && this.subscription.unsubscribe(); - this.logoutObserver && this.logoutObserver.off(); } } From b4f04e3478729b635008a61c26a43c603bd7911c Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 8 Jan 2018 14:42:08 +0100 Subject: [PATCH 21/24] MOBILE-2302 core: Improve how to obtain root nav controller --- src/app/app.component.ts | 19 ++++---- src/app/app.module.ts | 8 ++-- .../login/pages/credentials/credentials.ts | 2 +- src/core/login/pages/init/init.ts | 4 +- src/core/login/pages/reconnect/reconnect.ts | 2 +- .../login/pages/site-policy/site-policy.ts | 2 +- src/core/login/pages/site/site.ts | 2 +- src/core/login/pages/sites/sites.ts | 10 ++-- src/core/login/providers/helper.ts | 48 +++++++------------ src/providers/app.ts | 44 ++++++++++------- 10 files changed, 67 insertions(+), 74 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index ebc4e3bc8..72c1f61d5 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -12,10 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, ViewChild, AfterViewInit } from '@angular/core'; -import { Platform, Nav } from 'ionic-angular'; +import { Component, OnInit } from '@angular/core'; +import { Platform } from 'ionic-angular'; import { StatusBar } from '@ionic-native/status-bar'; import { SplashScreen } from '@ionic-native/splash-screen'; +import { CoreAppProvider } from '../providers/app'; import { CoreEventsProvider } from '../providers/events'; import { CoreLoggerProvider } from '../providers/logger'; import { CoreLoginHelperProvider } from '../core/login/providers/helper'; @@ -23,8 +24,7 @@ import { CoreLoginHelperProvider } from '../core/login/providers/helper'; @Component({ templateUrl: 'app.html' }) -export class MyApp implements AfterViewInit { - @ViewChild(Nav) navCtrl; +export class MoodleMobileApp implements OnInit { // Use the page name (string) because the page is lazy loaded (Ionic feature). That way we can load pages without // having to import them. The downside is that each page needs to implement a ngModule. rootPage:any = 'CoreLoginInitPage'; @@ -32,7 +32,8 @@ export class MyApp implements AfterViewInit { protected lastUrls = {}; constructor(private platform: Platform, statusBar: StatusBar, splashScreen: SplashScreen, logger: CoreLoggerProvider, - private eventsProvider: CoreEventsProvider, private loginHelper: CoreLoginHelperProvider) { + private eventsProvider: CoreEventsProvider, private loginHelper: CoreLoginHelperProvider, + private appProvider: CoreAppProvider) { this.logger = logger.getInstance('AppComponent'); platform.ready().then(() => { @@ -45,14 +46,12 @@ export class MyApp implements AfterViewInit { } /** - * View has been initialized. + * Component being initialized. */ - ngAfterViewInit() { - this.loginHelper.setNavCtrl(this.navCtrl); - + ngOnInit() { // Go to sites page when user is logged out. this.eventsProvider.on(CoreEventsProvider.LOGOUT, () => { - this.navCtrl.setRoot('CoreLoginSitesPage'); + this.appProvider.getRootNavController().setRoot('CoreLoginSitesPage'); }); // Listen for session expired events. diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 6b6d5c152..5924e2f89 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -25,7 +25,7 @@ import { Keyboard } from '@ionic-native/keyboard'; import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; import { TranslateHttpLoader } from '@ngx-translate/http-loader'; -import { MyApp } from './app.component'; +import { MoodleMobileApp } from './app.component'; import { CoreInterceptor } from '../classes/interceptor'; import { CoreLoggerProvider } from '../providers/logger'; import { CoreDbProvider } from '../providers/db'; @@ -66,13 +66,13 @@ export function createTranslateLoader(http: HttpClient) { @NgModule({ declarations: [ - MyApp + MoodleMobileApp ], imports: [ BrowserModule, HttpClientModule, // HttpClient is used to make JSON requests. It fails for HEAD requests because there is no content. HttpModule, - IonicModule.forRoot(MyApp, { + IonicModule.forRoot(MoodleMobileApp, { pageTransition: 'ios-transition' }), TranslateModule.forRoot({ @@ -90,7 +90,7 @@ export function createTranslateLoader(http: HttpClient) { ], bootstrap: [IonicApp], entryComponents: [ - MyApp + MoodleMobileApp ], providers: [ { diff --git a/src/core/login/pages/credentials/credentials.ts b/src/core/login/pages/credentials/credentials.ts index 50b5d05a1..8d0dc4138 100644 --- a/src/core/login/pages/credentials/credentials.ts +++ b/src/core/login/pages/credentials/credentials.ts @@ -219,7 +219,7 @@ export class CoreLoginCredentialsPage { // } // }); } else { - return this.loginHelper.goToSiteInitialPage(this.navCtrl, true); + return this.loginHelper.goToSiteInitialPage(); } }); }).catch((error) => { diff --git a/src/core/login/pages/init/init.ts b/src/core/login/pages/init/init.ts index 3f12be846..b122908f9 100644 --- a/src/core/login/pages/init/init.ts +++ b/src/core/login/pages/init/init.ts @@ -74,13 +74,13 @@ export class CoreLoginInitPage { protected loadPage() : void { if (this.sitesProvider.isLoggedIn()) { if (!this.loginHelper.isSiteLoggedOut()) { - this.loginHelper.goToSiteInitialPage(this.navCtrl, true); + this.loginHelper.goToSiteInitialPage(); } } else { this.sitesProvider.hasSites().then(() => { this.navCtrl.setRoot('CoreLoginSitesPage'); }, () => { - this.loginHelper.goToAddSite(this.navCtrl, true); + this.loginHelper.goToAddSite(true); }); } } diff --git a/src/core/login/pages/reconnect/reconnect.ts b/src/core/login/pages/reconnect/reconnect.ts index 680f0c1f8..aaa744056 100644 --- a/src/core/login/pages/reconnect/reconnect.ts +++ b/src/core/login/pages/reconnect/reconnect.ts @@ -140,7 +140,7 @@ export class CoreLoginReconnectPage { // Page defined, go to that page instead of site initial page. return this.navCtrl.setRoot(this.pageName, this.pageParams); } else { - return this.loginHelper.goToSiteInitialPage(this.navCtrl, true); + return this.loginHelper.goToSiteInitialPage(); } }).catch((error) => { // Site deleted? Go back to login page. diff --git a/src/core/login/pages/site-policy/site-policy.ts b/src/core/login/pages/site-policy/site-policy.ts index c344efbec..dac709cde 100644 --- a/src/core/login/pages/site-policy/site-policy.ts +++ b/src/core/login/pages/site-policy/site-policy.ts @@ -110,7 +110,7 @@ export class CoreLoginSitePolicyPage { return this.currentSite.invalidateWsCache().catch(() => { // Ignore errors. }).then(() => { - return this.loginHelper.goToSiteInitialPage(this.navCtrl, true); + return this.loginHelper.goToSiteInitialPage(); }); }).catch((error) => { this.domUtils.showErrorModalDefault(error.message, 'Error accepting site policy.'); diff --git a/src/core/login/pages/site/site.ts b/src/core/login/pages/site/site.ts index 26ff5b05d..bd4af2e9b 100644 --- a/src/core/login/pages/site/site.ts +++ b/src/core/login/pages/site/site.ts @@ -74,7 +74,7 @@ export class CoreLoginSitePage { // It's a demo site. this.sitesProvider.getUserToken(siteData.url, siteData.username, siteData.password).then((data) => { return this.sitesProvider.newSite(data.siteUrl, data.token, data.privateToken).then(() => { - return this.loginHelper.goToSiteInitialPage(this.navCtrl, true); + return this.loginHelper.goToSiteInitialPage(); }, (error) => { this.domUtils.showErrorModal(error); }); diff --git a/src/core/login/pages/sites/sites.ts b/src/core/login/pages/sites/sites.ts index d6ba555cd..3e6fa76c7 100644 --- a/src/core/login/pages/sites/sites.ts +++ b/src/core/login/pages/sites/sites.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Component } from '@angular/core'; -import { IonicPage, NavController } from 'ionic-angular'; +import { IonicPage } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreLoggerProvider } from '../../../../providers/logger'; import { CoreSitesProvider, CoreSiteBasicInfo } from '../../../../providers/sites'; @@ -34,7 +34,7 @@ export class CoreLoginSitesPage { showDelete: boolean; protected logger; - constructor(private navCtrl: NavController, private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider, + constructor(private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider, private sitesProvider: CoreSitesProvider, private loginHelper: CoreLoginHelperProvider, private translate: TranslateService, logger: CoreLoggerProvider) { this.logger = logger.getInstance('CoreLoginSitesPage'); @@ -85,7 +85,7 @@ export class CoreLoginSitesPage { * Go to the page to add a site. */ add() : void { - this.loginHelper.goToAddSite(this.navCtrl, false); + this.loginHelper.goToAddSite(false); } /** @@ -108,7 +108,7 @@ export class CoreLoginSitesPage { // If there are no sites left, go to add site. this.sitesProvider.hasNoSites().then(() => { - this.loginHelper.goToAddSite(this.navCtrl, true); + this.loginHelper.goToAddSite(true); }); }).catch((error) => { this.logger.error('Error deleting site ' + site.id, error); @@ -131,7 +131,7 @@ export class CoreLoginSitesPage { this.sitesProvider.loadSite(siteId).then(() => { if (!this.loginHelper.isSiteLoggedOut()) { - return this.loginHelper.goToSiteInitialPage(this.navCtrl, true); + return this.loginHelper.goToSiteInitialPage(); } }).catch((error) => { this.logger.error('Error loading site ' + siteId, error); diff --git a/src/core/login/providers/helper.ts b/src/core/login/providers/helper.ts index 571319de1..7ffc71213 100644 --- a/src/core/login/providers/helper.ts +++ b/src/core/login/providers/helper.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { NavController, Platform } from 'ionic-angular'; +import { Platform } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '../../../providers/app'; import { CoreConfigProvider } from '../../../providers/config'; @@ -46,7 +46,6 @@ export class CoreLoginHelperProvider { protected logger; protected isSSOConfirmShown = false; protected isOpenEditAlertShown = false; - protected navCtrl: NavController; lastInAppUrl: string; waitingForBrowser = false; @@ -142,9 +141,9 @@ export class CoreLoginHelperProvider { }).then(() => { if (siteData.pageName) { // State defined, go to that state instead of site initial page. - this.navCtrl.push(siteData.pageName, siteData.pageParams); + this.appProvider.getRootNavController().push(siteData.pageName, siteData.pageParams); } else { - this.goToSiteInitialPage(this.navCtrl, true); + this.goToSiteInitialPage(); } }).catch((errorMessage) => { if (typeof errorMessage == 'string' && errorMessage != '') { @@ -176,8 +175,9 @@ export class CoreLoginHelperProvider { * Function called when an SSO InAppBrowser is closed or the app is resumed. Check if user needs to be logged out. */ checkLogout() { + let navCtrl = this.appProvider.getRootNavController(); if (!this.appProvider.isSSOAuthenticationOngoing() && this.sitesProvider.isLoggedIn() && - this.sitesProvider.getCurrentSite().isLoggedOut() && this.navCtrl.getActive().name == 'CoreLoginReconnectPage') { + this.sitesProvider.getCurrentSite().isLoggedOut() && navCtrl.getActive().name == 'CoreLoginReconnectPage') { // User must reauthenticate but he closed the InAppBrowser without doing so, logout him. this.sitesProvider.logout(); } @@ -345,11 +345,10 @@ export class CoreLoginHelperProvider { * Go to the page to add a new site. * If a fixed URL is configured, go to credentials instead. * - * @param {NavController} navCtrl The NavController instance to use. * @param {boolean} [setRoot] True to set the new page as root, false to add it to the stack. * @return {Promise} Promise resolved when done. */ - goToAddSite(navCtrl: NavController, setRoot?: boolean) : Promise { + goToAddSite(setRoot?: boolean) : Promise { let pageName, params; @@ -365,25 +364,19 @@ export class CoreLoginHelperProvider { } if (setRoot) { - return navCtrl.setRoot(pageName, params, {animate: false}); + return this.appProvider.getRootNavController().setRoot(pageName, params, {animate: false}); } else { - return navCtrl.push(pageName, params); + return this.appProvider.getRootNavController().push(pageName, params); } } /** * Go to the initial page of a site depending on 'userhomepage' setting. * - * @param {NavController} navCtrl The NavController instance to use. - * @param {boolean} [setRoot] True to set the new page as root, false to add it to the stack. * @return {Promise} Promise resolved when done. */ - goToSiteInitialPage(navCtrl: NavController, setRoot?: boolean) : Promise { - if (setRoot) { - return navCtrl.setRoot('CoreMainMenuPage', {}, {animate: false}); - } else { - return navCtrl.push('CoreMainMenuPage'); - } + goToSiteInitialPage() : Promise { + return this.appProvider.getRootNavController().setRoot('CoreMainMenuPage'); // return this.isMyOverviewEnabled().then((myOverview) => { // let myCourses = !myOverview && this.isMyCoursesEnabled(), // site = this.sitesProvider.getCurrentSite(), @@ -625,7 +618,7 @@ export class CoreLoginHelperProvider { protected loadSiteAndPage(page: string, params: any, siteId: string) : void { if (siteId == CoreConstants.noSiteId) { // Page doesn't belong to a site, just load the page. - this.navCtrl.setRoot(page, params); + this.appProvider.getRootNavController().setRoot(page, params); } else { let modal = this.domUtils.showModalLoading(); this.sitesProvider.loadSite(siteId).then(() => { @@ -634,7 +627,7 @@ export class CoreLoginHelperProvider { } }).catch(() => { // Site doesn't exist. - this.navCtrl.setRoot('CoreLoginSitesPage') + this.appProvider.getRootNavController().setRoot('CoreLoginSitesPage') }).finally(() => { modal.dismiss(); }); @@ -648,7 +641,7 @@ export class CoreLoginHelperProvider { * @param {any} params Params to pass to the page. */ protected loadPageInMainMenu(page: string, params: any) : void { - this.navCtrl.setRoot('CoreMainMenuPage', {redirectPage: page, redirectParams: params}) + this.appProvider.getRootNavController().setRoot('CoreMainMenuPage', {redirectPage: page, redirectParams: params}) } /** @@ -839,7 +832,7 @@ export class CoreLoginHelperProvider { if (siteId) { this.loadSiteAndPage(page, params, siteId); } else { - this.navCtrl.setRoot('CoreLoginSitesPage') + this.appProvider.getRootNavController().setRoot('CoreLoginSitesPage') } } } @@ -919,7 +912,7 @@ export class CoreLoginHelperProvider { } else { let info = currentSite.getInfo(); if (typeof info != 'undefined' && typeof info.username != 'undefined') { - this.navCtrl.setRoot('CoreLoginReconnectPage', { + this.appProvider.getRootNavController().setRoot('CoreLoginReconnectPage', { infoSiteUrl: info.siteurl, siteUrl: result.siteUrl, siteId: siteId, @@ -939,15 +932,6 @@ export class CoreLoginHelperProvider { }); } - /** - * Set a NavController to use. - * - * @param {NavController} navCtrl Nav controller. - */ - setNavCtrl(navCtrl: NavController) : void { - this.navCtrl = navCtrl; - } - /** * Check if a confirm should be shown to open a SSO authentication. * @@ -976,7 +960,7 @@ export class CoreLoginHelperProvider { return; } - this.navCtrl.setRoot('CoreLoginSitePolicyPage', {siteId: siteId}); + this.appProvider.getRootNavController().setRoot('CoreLoginSitePolicyPage', {siteId: siteId}); } /** diff --git a/src/providers/app.ts b/src/providers/app.ts index 46dac2a50..2f343a95e 100644 --- a/src/providers/app.ts +++ b/src/providers/app.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { Platform } from 'ionic-angular'; +import { Platform, App, NavController } from 'ionic-angular'; import { Keyboard } from '@ionic-native/keyboard'; import { Network } from '@ionic-native/network'; @@ -46,7 +46,7 @@ export class CoreAppProvider { ssoAuthenticationPromise : Promise; isKeyboardShown: boolean = false; - constructor(dbProvider: CoreDbProvider, private platform: Platform, private keyboard: Keyboard, + constructor(dbProvider: CoreDbProvider, private platform: Platform, private keyboard: Keyboard, private appCtrl: App, private network: Network, logger: CoreLoggerProvider) { this.logger = logger.getInstance('CoreAppProvider'); this.db = dbProvider.getDB(this.DBNAME); @@ -76,7 +76,7 @@ export class CoreAppProvider { */ canRecordMedia() : boolean { return !!(window).MediaRecorder; - }; + } /** * Closes the keyboard. @@ -85,7 +85,7 @@ export class CoreAppProvider { if (this.isMobile()) { this.keyboard.close(); } - }; + } /** * Get the application global database. @@ -94,7 +94,17 @@ export class CoreAppProvider { */ getDB() : SQLiteDB { return this.db; - }; + } + + /** + * Get the app's root NavController. + * + * @return {NavController} Root NavController. + */ + getRootNavController() : NavController { + // getRootNav is deprecated. Get the first root nav, there should always be one. + return this.appCtrl.getRootNavs()[0]; + } /** * Checks if the app is running in a desktop environment (not browser). @@ -104,7 +114,7 @@ export class CoreAppProvider { isDesktop() : boolean { let process = (window).process; return !!(process && process.versions && typeof process.versions.electron != 'undefined'); - }; + } /** * Check if the keyboard is visible. @@ -113,7 +123,7 @@ export class CoreAppProvider { */ isKeyboardVisible() : boolean { return this.isKeyboardShown; - }; + } /** * Check if the app is running in a Linux environment. @@ -158,7 +168,7 @@ export class CoreAppProvider { */ isMobile() : boolean { return this.platform.is('cordova'); - }; + } /** * Returns whether we are online. @@ -172,7 +182,7 @@ export class CoreAppProvider { online = true; } return online; - }; + } /* * Check if device uses a limited connection. @@ -188,7 +198,7 @@ export class CoreAppProvider { let limited = [Connection.CELL_2G, Connection.CELL_3G, Connection.CELL_4G, Connection.CELL]; return limited.indexOf(type) > -1; - }; + } /** * Check if the app is running in a Windows environment. @@ -216,7 +226,7 @@ export class CoreAppProvider { if (this.isMobile() && !this.platform.is('ios')) { this.keyboard.show(); } - }; + } /** * Start an SSO authentication process. @@ -243,7 +253,7 @@ export class CoreAppProvider { this.ssoAuthenticationPromise.then(() => { clearTimeout(cancelTimeout); }); - }; + } /** * Finish an SSO authentication process. @@ -253,7 +263,7 @@ export class CoreAppProvider { (this.ssoAuthenticationPromise).resolve && (this.ssoAuthenticationPromise).resolve(); this.ssoAuthenticationPromise = undefined; } - }; + } /** * Check if there's an ongoing SSO authentication process. @@ -262,7 +272,7 @@ export class CoreAppProvider { */ isSSOAuthenticationOngoing() : boolean { return !!this.ssoAuthenticationPromise; - }; + } /** * Returns a promise that will be resolved once SSO authentication finishes. @@ -271,7 +281,7 @@ export class CoreAppProvider { */ waitForSSOAuthentication() : Promise { return this.ssoAuthenticationPromise || Promise.resolve(); - }; + } /** * Retrieve redirect data. @@ -299,7 +309,7 @@ export class CoreAppProvider { } return {}; - }; + } /** * Store redirect params. @@ -317,5 +327,5 @@ export class CoreAppProvider { localStorage.setItem('mmCoreRedirectTime', String(Date.now())); } catch(ex) {} } - }; + } } From 64460157c16c43d10b3eff28f092c1a27ece3109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 29 Dec 2017 18:05:52 +0100 Subject: [PATCH 22/24] MOBILE-2302 courses: Adapt styles --- src/app/app.ios.scss | 18 +++- src/app/app.md.scss | 6 +- src/app/app.scss | 69 ++++++++++++-- src/app/app.wp.scss | 4 + src/components/empty-box/empty-box.html | 8 +- src/components/empty-box/empty-box.scss | 54 +++++++++++ src/components/file/file.scss | 30 +----- src/components/iframe/iframe.html | 4 +- src/components/loading/loading.html | 9 +- src/components/loading/loading.scss | 25 +++-- src/components/progress-bar/progress-bar.html | 2 +- src/components/progress-bar/progress-bar.scss | 20 ++-- src/components/search-box/search-box.html | 2 +- src/components/search-box/search-box.scss | 3 + .../show-password/show-password.scss | 5 + .../course-list-item/course-list-item.html | 11 +-- .../course-list-item/course-list-item.scss | 2 +- .../course-progress/course-progress.html | 21 +++-- .../course-progress/course-progress.scss | 12 ++- .../course-progress/course-progress.ts | 12 +-- .../overview-events/overview-events.html | 79 ++++++---------- .../overview-events/overview-events.scss | 22 +++++ .../pages/course-preview/course-preview.html | 14 ++- .../pages/my-overview/my-overview.html | 94 ++++++++++--------- .../courses/pages/my-overview/my-overview.ts | 8 -- .../self-enrol-password.html | 4 +- src/core/courses/providers/handlers.ts | 4 +- src/core/emulator/providers/clipboard.ts | 2 +- .../login/pages/credentials/credentials.html | 12 +-- .../login/pages/credentials/credentials.scss | 9 +- .../pages/email-signup/email-signup.html | 2 +- src/core/login/pages/init/init.html | 4 +- src/core/login/pages/init/init.scss | 16 ++-- src/core/login/pages/reconnect/reconnect.html | 14 +-- src/core/login/pages/reconnect/reconnect.scss | 11 +-- src/core/login/pages/site/site.html | 2 +- src/core/mainmenu/pages/more/more.html | 6 +- src/directives/auto-focus.ts | 2 +- src/directives/format-text.ts | 26 ++--- src/directives/keep-keyboard.ts | 4 +- src/providers/sites.ts | 2 +- src/providers/utils/dom.ts | 15 +-- src/theme/variables.scss | 45 ++++++--- 43 files changed, 423 insertions(+), 291 deletions(-) diff --git a/src/app/app.ios.scss b/src/app/app.ios.scss index 9b7ffd32d..829e70af8 100644 --- a/src/app/app.ios.scss +++ b/src/app/app.ios.scss @@ -8,9 +8,25 @@ color: color($colors, primary, base); } +.col[align-self-stretch] .card-ios { + height: calc(100% - #{($card-ios-margin-end + $card-ios-margin-start)}); +} + +// Top tabs +// ------------------------- +.ios .core-top-tabbar { + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + > a { + font-size: 1.6rem; + } +} + // Highlights inside the input element. -@if ($mm-text-input-ios-show-highlight) { +@if ($core-text-input-ios-show-highlight) { .card-ios, .list-ios { // In order to get a 2px border we need to add an inset // box-shadow 1px (this is to avoid the div resizing) diff --git a/src/app/app.md.scss b/src/app/app.md.scss index 552f800a2..c62a2141f 100644 --- a/src/app/app.md.scss +++ b/src/app/app.md.scss @@ -8,8 +8,12 @@ color: color($colors, primary, base); } +.col[align-self-stretch] .card-md { + height: calc(100% - #{($card-md-margin-end + $card-md-margin-start)}); +} + // Highlights inside the input element. -@if ($mm-text-input-md-show-highlight) { +@if ($core-text-input-md-show-highlight) { .card-md, .list-md { // In order to get a 2px border we need to add an inset // box-shadow 1px (this is to avoid the div resizing) diff --git a/src/app/app.scss b/src/app/app.scss index ed0a5508b..3b5ab9aad 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -23,10 +23,16 @@ .text-right { text-align: right; } .text-center { text-align: center; } .text-justify { text-align: justify; } - +.clearfix { + &:after { + content: ""; + display: table; + clear: both; + } +} @media only screen and (min-width: 430px) { - .mm-center-view .scroll-content { + .core-center-view .scroll-content { display: flex!important; align-content: center !important; align-items: center !important; @@ -37,6 +43,18 @@ } } +@media only screen and (max-width: 768px) { + .hidden-phone { + display: none !important; + } +} + +@media only screen and (min-width: 769px) { + .hidden-tablet { + display: none !important; + } +} + // Define an alternative way to set a heading in an item without using a heading tag. // This is done for accessibility reasons when a heading is semantically incorrect. @@ -45,7 +63,7 @@ margin: 0; } -.mm-oauth-icon, .item.mm-oauth-icon, .list .item.mm-oauth-icon { +.core-oauth-icon, .item.core-oauth-icon, .list .item.core-oauth-icon { min-height: 32px; img, .label { max-height: 32px; @@ -60,7 +78,7 @@ } } -.mm-bold, .mm-bold .label { +.core-bold, .core-bold .label { font-weight: bold; } @@ -128,23 +146,23 @@ core-format-text[maxHeight], *[core-format-text][maxHeight] { } // This is to allow clicks in radio/checkbox content. - &.mm-text-formatted { + &.core-text-formatted { cursor: pointer; - .mm-show-more { + .core-show-more { display: none; } - &:not(.mm-shortened) { + &:not(.core-shortened) { max-height: none !important; } - &.mm-shortened { + &.core-shortened { color: $gray-darker; overflow: hidden; min-height: 50px; - .mm-show-more { + .core-show-more { color: color($colors, dark); text-align: right; font-size: 14px; @@ -166,7 +184,7 @@ core-format-text[maxHeight], *[core-format-text][maxHeight] { */ } - &.mm-expand-in-fullview .mm-show-more:after { + &.core-expand-in-fullview .core-show-more:after { // content: $ionicon-var-chevron-right; @todo } @@ -225,3 +243,34 @@ core-format-text, *[core-format-text] { max-height: $item-media-height; } } + +// Ionic fix. Button can occupy all page if not. +ion-select { + position: relative +} + +// Top tabs +// ------------------------- + +.core-top-tabbar { + @include position(null, null, 0, 0); + + z-index: $z-index-toolbar; + display: flex; + width: 100%; + background: $core-top-tabs-background; + + > a { + @extend .tab-button; + + background: $core-top-tabs-background; + color: $core-top-tabs-color !important; + border-bottom: 1px solid $core-top-tabs-border; + font-size: 1.6rem; + + &[aria-selected=true] { + color: $core-top-tabs-color-active !important; + border-bottom: 2px solid $core-top-tabs-color-active; + } + } +} diff --git a/src/app/app.wp.scss b/src/app/app.wp.scss index 29afff5a3..c0544152c 100644 --- a/src/app/app.wp.scss +++ b/src/app/app.wp.scss @@ -7,3 +7,7 @@ .button-wp-light { color: color($colors, primary, base); } + +.col[align-self-stretch] .card-wp { + height: calc(100% - #{($card-wp-margin-end + $card-wp-margin-start)}); +} diff --git a/src/components/empty-box/empty-box.html b/src/components/empty-box/empty-box.html index 1c43cb9bf..13229943f 100644 --- a/src/components/empty-box/empty-box.html +++ b/src/components/empty-box/empty-box.html @@ -1,8 +1,8 @@ -
-
+
+
- -

{{ message }}

+ +

{{ message }}

\ No newline at end of file diff --git a/src/components/empty-box/empty-box.scss b/src/components/empty-box/empty-box.scss index ec407edf6..0b7562355 100644 --- a/src/components/empty-box/empty-box.scss +++ b/src/components/empty-box/empty-box.scss @@ -1,3 +1,57 @@ core-empty-box { + .core-empty-box { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: table; + height: 100%; + width: 100%; + z-index: -1; + margin: 0; + padding: 0; + clear: both; + .core-empty-box-content { + color: $black; + margin: 0; + display: table-cell; + text-align: center; + vertical-align: middle; + } + + &.core-empty-box-inline { + position: relative; + top: initial; + left: initial; + right: initial; + z-index: initial; + } + + .icon { + font-size: 120px; + } + img { + height: 125px; + width: 145px; + } + p { + font-size: 120%; + } + } + + @media only screen and (max-height: 420px) { + .core-empty-box { + position: relative; + + .icon { + font-size: 100px; + } + img { + height: 104px; + width: 121px; + } + } + } } diff --git a/src/components/file/file.scss b/src/components/file/file.scss index 6279a3b37..95127e438 100644 --- a/src/components/file/file.scss +++ b/src/components/file/file.scss @@ -1,28 +1,2 @@ -core-loading { - .mm-loading-container { - width: 100%; - text-align: center; - padding-top: 10px; - clear: both; - } - - .mm-loading-content { - padding-bottom: 1px; /* This makes height be real */ - } - - &.mm-loading-noheight .mm-loading-content { - height: auto; - } -} - -.scroll-content > .padding > core-loading > .mm-loading-container, -ion-content[padding] > .scroll-content > core-loading > .mm-loading-container, -.mm-loading-center .mm-loading-container { - display: table; - - .mm-loading-spinner { - display: table-cell; - text-align: center; - vertical-align: middle; - } -} +core-file { +} \ No newline at end of file diff --git a/src/components/iframe/iframe.html b/src/components/iframe/iframe.html index 75cea2664..69ace4cf8 100644 --- a/src/components/iframe/iframe.html +++ b/src/components/iframe/iframe.html @@ -1,4 +1,4 @@ -
- +
+
\ No newline at end of file diff --git a/src/components/loading/loading.html b/src/components/loading/loading.html index e79daa1ff..d6a224a91 100644 --- a/src/components/loading/loading.html +++ b/src/components/loading/loading.html @@ -1,8 +1,9 @@ -
- + +
+ -

{{message}}

+

{{message}}

- + \ No newline at end of file diff --git a/src/components/loading/loading.scss b/src/components/loading/loading.scss index 6279a3b37..752623059 100644 --- a/src/components/loading/loading.scss +++ b/src/components/loading/loading.scss @@ -1,26 +1,37 @@ core-loading { - .mm-loading-container { + .core-loading-container { width: 100%; text-align: center; padding-top: 10px; clear: both; } - .mm-loading-content { + .core-loading-content { padding-bottom: 1px; /* This makes height be real */ } - &.mm-loading-noheight .mm-loading-content { + &.core-loading-noheight .core-loading-content { height: auto; } } -.scroll-content > .padding > core-loading > .mm-loading-container, -ion-content[padding] > .scroll-content > core-loading > .mm-loading-container, -.mm-loading-center .mm-loading-container { +.scroll-content > .padding > core-loading > .core-loading-container, +ion-content[padding] > .scroll-content > core-loading > .core-loading-container, +.core-loading-center .core-loading-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; display: table; + height: 100%; + width: 100%; + z-index: 1; + margin: 0; + padding: 0; + clear: both; - .mm-loading-spinner { + .core-loading-spinner { display: table-cell; text-align: center; vertical-align: middle; diff --git a/src/components/progress-bar/progress-bar.html b/src/components/progress-bar/progress-bar.html index 3cc2055b0..7c7319034 100644 --- a/src/components/progress-bar/progress-bar.html +++ b/src/components/progress-bar/progress-bar.html @@ -4,5 +4,5 @@
- {{ 'core.percentagenumber' | translate: {$a: text} }} + {{ 'core.percentagenumber' | translate: {$a: text} }}
diff --git a/src/components/progress-bar/progress-bar.scss b/src/components/progress-bar/progress-bar.scss index a1d8ec63d..318a230bf 100644 --- a/src/components/progress-bar/progress-bar.scss +++ b/src/components/progress-bar/progress-bar.scss @@ -1,25 +1,25 @@ -$mm-progress-bar-height: 5px !default; +$core-progress-bar-height: 5px !default; core-progress-bar { padding-right: 55px; position: relative; display: block; - // @extend .clearfix; + @extend .clearfix; - .mm-progress-text { + .core-progress-text { margin-left: 10px; - line-height: 35px; + line-height: normal; + font-size: 1.4rem; color: $gray-darker; right: 0; - top: 0; - color: #626262; + top: -6px; position: absolute; } progress { -webkit-appearance: none; appearance: none; - height: $mm-progress-bar-height; + height: $core-progress-bar-height; margin: 15px 0; padding: 0; display: block; @@ -34,18 +34,18 @@ core-progress-bar { .progress-bar-fallback span, &[value]::-webkit-progress-value { - background-color: $mm-color-light; + background-color: $core-color-light; border-radius: 2px; } .progress-bar-fallback { width: 100%; - height: $mm-progress-bar-height; + height: $core-progress-bar-height; display: block; position: relative; span { - height: $mm-progress-bar-height; + height: $core-progress-bar-height; display: block; } } diff --git a/src/components/search-box/search-box.html b/src/components/search-box/search-box.html index bc0b8a5da..7772b67e8 100644 --- a/src/components/search-box/search-box.html +++ b/src/components/search-box/search-box.html @@ -2,7 +2,7 @@
- diff --git a/src/components/search-box/search-box.scss b/src/components/search-box/search-box.scss index d451c7559..c2f5de7b5 100644 --- a/src/components/search-box/search-box.scss +++ b/src/components/search-box/search-box.scss @@ -3,4 +3,7 @@ core-search-box { margin: 0; padding: ($content-padding / 2) $content-padding; } + .item.item-input.item-block .item-inner ion-input { + border-bottom: 0; + } } diff --git a/src/components/show-password/show-password.scss b/src/components/show-password/show-password.scss index 5e5621493..919b6dce4 100644 --- a/src/components/show-password/show-password.scss +++ b/src/components/show-password/show-password.scss @@ -16,6 +16,11 @@ core-show-password { margin-top: 0; margin-bottom: 0; } + + .core-ioninput-password { + padding-top: 0; + padding-bottom: 0; + } } .md { diff --git a/src/core/courses/components/course-list-item/course-list-item.html b/src/core/courses/components/course-list-item/course-list-item.html index 1c105977b..26bd82906 100644 --- a/src/core/courses/components/course-list-item/course-list-item.html +++ b/src/core/courses/components/course-list-item/course-list-item.html @@ -1,15 +1,12 @@ - +

- - - + + + - - -
diff --git a/src/core/courses/components/course-list-item/course-list-item.scss b/src/core/courses/components/course-list-item/course-list-item.scss index 2487cb6e9..d5fe709e3 100644 --- a/src/core/courses/components/course-list-item/course-list-item.scss +++ b/src/core/courses/components/course-list-item/course-list-item.scss @@ -1,5 +1,5 @@ core-courses-course-list-item { - .mm-course-enrollment-img { + .core-course-enrollment-img { max-width: 16px; max-height: 16px; } diff --git a/src/core/courses/components/course-progress/course-progress.html b/src/core/courses/components/course-progress/course-progress.html index e66ef5389..08b954147 100644 --- a/src/core/courses/components/course-progress/course-progress.html +++ b/src/core/courses/components/course-progress/course-progress.html @@ -1,19 +1,22 @@ - -

- - - +
+

+ + - +
- +

- +

+ + +
diff --git a/src/core/courses/components/course-progress/course-progress.scss b/src/core/courses/components/course-progress/course-progress.scss index e24e0ae82..4587fe8ca 100644 --- a/src/core/courses/components/course-progress/course-progress.scss +++ b/src/core/courses/components/course-progress/course-progress.scss @@ -1,2 +1,12 @@ -core-courses-course-progress { +core-courses-course-progress.core-courseoverview { + @media (max-width: 576px) { + ion-card.card { + margin: 0; + border-radius: 0; + box-shadow: none; + border-bottom: 1px solid $list-border-color; + width: 100%; + height: 100% !important; + } + } } diff --git a/src/core/courses/components/course-progress/course-progress.ts b/src/core/courses/components/course-progress/course-progress.ts index a3dd08c3c..e7a2fb49c 100644 --- a/src/core/courses/components/course-progress/course-progress.ts +++ b/src/core/courses/components/course-progress/course-progress.ts @@ -15,14 +15,13 @@ import { Component, Input, OnInit } from '@angular/core'; import { NavController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; -import { CoreUtilsProvider } from '../../../../providers/utils/utils'; /** * This component is meant to display a course for a list of courses with progress. * * Example usage: * - * + * * */ @Component({ @@ -31,22 +30,20 @@ import { CoreUtilsProvider } from '../../../../providers/utils/utils'; }) export class CoreCoursesCourseProgressComponent implements OnInit { @Input() course: any; // The course to render. - @Input() showSummary?: boolean|string; // Whether to show the summary. - actionsLoaded = true; - prefetchCourseIcon: string; + isDownloading: boolean; protected obsStatus; protected downloadText; protected downloadingText; protected downloadButton = { isDownload: true, - className: 'mm-download-course', + className: 'core-download-course', priority: 1000 }; protected buttons; - constructor(private navCtrl: NavController, private translate: TranslateService, private utils: CoreUtilsProvider) { + constructor(private navCtrl: NavController, private translate: TranslateService) { this.downloadText = this.translate.instant('core.course.downloadcourse'); this.downloadingText = this.translate.instant('core.downloading'); } @@ -56,7 +53,6 @@ export class CoreCoursesCourseProgressComponent implements OnInit { */ ngOnInit() { // @todo: Handle course prefetch. - this.showSummary = this.utils.isTrueOrOne(this.showSummary); } /** diff --git a/src/core/courses/components/overview-events/overview-events.html b/src/core/courses/components/overview-events/overview-events.html index b7240d0f8..e39c479b2 100644 --- a/src/core/courses/components/overview-events/overview-events.html +++ b/src/core/courses/components/overview-events/overview-events.html @@ -1,62 +1,45 @@ - + - - {{event.action.itemcount}}

{{event.timesort * 1000 | coreFormatDate:"dfmediumdate" }}

+ + {{event.action.itemcount}}
- -
{{ 'core.courses.recentlyoverdue' | translate }}
-
    -
  • - -
  • -
-
+ + {{ 'core.courses.recentlyoverdue' | translate }} + + + + - -
{{ 'core.today' | translate }}
-
    -
  • - -
  • -
-
+ + {{ 'core.courses.next7days' | translate }} + + + + - -
{{ 'core.courses.next7days' | translate }}
-
    -
  • - -
  • -
-
+ + {{ 'core.courses.next30days' | translate }} + + + + - -
{{ 'core.courses.next30days' | translate }}
-
    -
  • - -
  • -
-
+ + {{ 'core.courses.future' | translate }} + + + + - -
{{ 'core.courses.future' | translate }}
-
    -
  • - -
  • -
-
- -
+
diff --git a/src/core/courses/components/overview-events/overview-events.scss b/src/core/courses/components/overview-events/overview-events.scss index e24e0ae82..4adcffdff 100644 --- a/src/core/courses/components/overview-events/overview-events.scss +++ b/src/core/courses/components/overview-events/overview-events.scss @@ -1,2 +1,24 @@ core-courses-course-progress { + + .core-course-module-handler.item-md.item-block .item-inner { + border-bottom: 1px solid $list-md-border-color; + } + + .core-course-module-handler.item-ios.item-block .item-inner { + border-bottom: $hairlines-width solid $list-ios-border-color; + } + + .core-course-module-handler.item-wp.item-block .item-inner { + border-bottom: 1px solid $list-wp-border-color; + } + + .core-course-module-handler.item:last-child .item-inner { + border-bottom: 0; + } + + .core-course-module-handler.item .item-heading:first-child { + margin-top: 0; + } } + + diff --git a/src/core/courses/pages/course-preview/course-preview.html b/src/core/courses/pages/course-preview/course-preview.html index 9bda961bf..58db99af7 100644 --- a/src/core/courses/pages/course-preview/course-preview.html +++ b/src/core/courses/pages/course-preview/course-preview.html @@ -10,7 +10,7 @@ - +

{{course.categoryname}}

@@ -29,13 +29,13 @@

{{ instance.name }}

- +

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

{{ 'core.paymentinstant' | translate }}

- +

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

@@ -49,16 +49,14 @@

{{ 'core.course.contents' | translate }}

-
-
- + - + diff --git a/src/core/courses/pages/my-overview/my-overview.html b/src/core/courses/pages/my-overview/my-overview.html index d7be21e0e..84f43acd7 100644 --- a/src/core/courses/pages/my-overview/my-overview.html +++ b/src/core/courses/pages/my-overview/my-overview.html @@ -3,12 +3,12 @@ {{ 'core.courses.courseoverview' | translate }} + - @@ -18,61 +18,63 @@ -
- {{ 'core.courses.timeline' | translate }} - {{ 'core.courses.courses' | translate }} + -
- +
+
{{ 'core.courses.sortbydates' | translate }} {{ 'core.courses.sortbycourses' | translate }} - - +
+ - -
- - - -
+ + + + + + + + + + - - - - - - {{ 'core.courses.inprogress' | translate }} - {{ 'core.courses.future' | translate }} - {{ 'core.courses.past' | translate }} - - - - - - - -
- - - - - -
- -
- -
+
+ +
+ + {{ 'core.courses.inprogress' | translate }} + {{ 'core.courses.future' | translate }} + {{ 'core.courses.past' | translate }} + + +
+
+ + + + +
+
+ + + + + + + - -
+
+ diff --git a/src/core/courses/pages/my-overview/my-overview.ts b/src/core/courses/pages/my-overview/my-overview.ts index f3643851c..77981683c 100644 --- a/src/core/courses/pages/my-overview/my-overview.ts +++ b/src/core/courses/pages/my-overview/my-overview.ts @@ -48,7 +48,6 @@ export class CoreCoursesMyOverviewPage { inprogress: [], future: [] }; - showGrid = true; showFilter = false; searchEnabled: boolean; filteredCourses: any[]; @@ -204,13 +203,6 @@ export class CoreCoursesMyOverviewPage { } } - /** - * Switch grid/list view. - */ - switchGrid() { - this.showGrid = !this.showGrid; - } - /** * Refresh the data. * diff --git a/src/core/courses/pages/self-enrol-password/self-enrol-password.html b/src/core/courses/pages/self-enrol-password/self-enrol-password.html index b9161587b..3294d64bc 100644 --- a/src/core/courses/pages/self-enrol-password/self-enrol-password.html +++ b/src/core/courses/pages/self-enrol-password/self-enrol-password.html @@ -9,11 +9,11 @@ - + - + diff --git a/src/core/courses/providers/handlers.ts b/src/core/courses/providers/handlers.ts index c003917b3..dd3338802 100644 --- a/src/core/courses/providers/handlers.ts +++ b/src/core/courses/providers/handlers.ts @@ -57,14 +57,14 @@ export class CoreCoursesMainMenuHandler implements CoreMainMenuHandler { icon: 'ionic', title: 'core.courses.courseoverview', page: 'CoreCoursesMyOverviewPage', - class: 'mm-courseoverview-handler' + class: 'core-courseoverview-handler' }; } else { return { icon: 'ionic', title: 'core.courses.mycourses', page: 'CoreCoursesMyCoursesPage', - class: 'mm-mycourses-handler' + class: 'core-mycourses-handler' }; } } diff --git a/src/core/emulator/providers/clipboard.ts b/src/core/emulator/providers/clipboard.ts index 89a427f8e..2cdd7435e 100644 --- a/src/core/emulator/providers/clipboard.ts +++ b/src/core/emulator/providers/clipboard.ts @@ -34,7 +34,7 @@ export class ClipboardMock extends Clipboard { } else { // In browser the text must be selected in order to copy it. Create a hidden textarea to put the text in it. this.copyTextarea = document.createElement('textarea'); - this.copyTextarea.className = 'mm-browser-copy-area'; + this.copyTextarea.className = 'core-browser-copy-area'; this.copyTextarea.setAttribute('aria-hidden', 'true'); document.body.appendChild(this.copyTextarea); } diff --git a/src/core/login/pages/credentials/credentials.html b/src/core/login/pages/credentials/credentials.html index b58d711b8..6aee0c300 100644 --- a/src/core/login/pages/credentials/credentials.html +++ b/src/core/login/pages/credentials/credentials.html @@ -3,7 +3,7 @@ {{ 'core.login.login' | translate }} - +
@@ -12,10 +12,10 @@ -

{{siteUrl}}

+

{{siteUrl}}

-

{{siteName}}

-

{{siteUrl}}

+

{{siteName}}

+

{{siteUrl}}

@@ -23,7 +23,7 @@ - + @@ -36,7 +36,7 @@ {{ 'core.login.potentialidps' | translate }} - diff --git a/src/core/login/pages/credentials/credentials.scss b/src/core/login/pages/credentials/credentials.scss index b940b3116..030525baf 100644 --- a/src/core/login/pages/credentials/credentials.scss +++ b/src/core/login/pages/credentials/credentials.scss @@ -4,11 +4,6 @@ page-core-login-credentials { background: radial-gradient(white, $gray-light); } - .mm-ioninput-password { - padding-top: 0; - padding-bottom: 0; - } - img { max-width: 100%; } @@ -25,8 +20,8 @@ page-core-login-credentials { border: 1px solid $gray; } - .mm-sitename, .mm-siteurl { - @if $mm-fixed-url { display: none; } + .core-sitename, .core-siteurl { + @if $core-fixed-url { display: none; } } .item-input { diff --git a/src/core/login/pages/email-signup/email-signup.html b/src/core/login/pages/email-signup/email-signup.html index 2a737e086..e284fd771 100644 --- a/src/core/login/pages/email-signup/email-signup.html +++ b/src/core/login/pages/email-signup/email-signup.html @@ -78,7 +78,7 @@ diff --git a/src/core/login/pages/init/init.html b/src/core/login/pages/init/init.html index d0ef72e4a..53461969c 100644 --- a/src/core/login/pages/init/init.html +++ b/src/core/login/pages/init/init.html @@ -1,6 +1,6 @@ -