Merge pull request #3272 from moodlehq/integration

Integration
main
Juan Leyva 2022-04-22 15:02:03 +02:00 committed by GitHub
commit 782d94f6c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
1490 changed files with 88245 additions and 36798 deletions

View File

@ -126,7 +126,7 @@ const appConfig = {
ignoreParameters: true, ignoreParameters: true,
}, },
], ],
'@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-redeclare': 'error', '@typescript-eslint/no-redeclare': 'error',
'@typescript-eslint/no-this-alias': 'error', '@typescript-eslint/no-this-alias': 'error',
'@typescript-eslint/no-unused-vars': 'error', '@typescript-eslint/no-unused-vars': 'error',
@ -139,7 +139,6 @@ const appConfig = {
'always', 'always',
], ],
'@typescript-eslint/type-annotation-spacing': 'error', '@typescript-eslint/type-annotation-spacing': 'error',
'@typescript-eslint/unified-signatures': 'error',
'header/header': [ 'header/header': [
2, 2,
'line', 'line',
@ -235,6 +234,11 @@ const appConfig = {
prev: '*', prev: '*',
next: 'return', next: 'return',
}, },
{
blankLine: 'always',
prev: '*',
next: 'function',
},
], ],
'prefer-arrow/prefer-arrow-functions': [ 'prefer-arrow/prefer-arrow-functions': [
'error', 'error',
@ -271,6 +275,7 @@ testsConfig['rules']['padded-blocks'] = [
switches: 'never', switches: 'never',
}, },
]; ];
testsConfig['rules']['jest/expect-expect'] = 'off';
testsConfig['plugins'].push('jest'); testsConfig['plugins'].push('jest');
testsConfig['extends'].push('plugin:jest/recommended'); testsConfig['extends'].push('plugin:jest/recommended');
@ -291,6 +296,7 @@ module.exports = {
'@angular-eslint/template/no-positive-tabindex': 'error', '@angular-eslint/template/no-positive-tabindex': 'error',
'@angular-eslint/template/accessibility-table-scope': 'error', '@angular-eslint/template/accessibility-table-scope': 'error',
'@angular-eslint/template/accessibility-valid-aria': 'error', '@angular-eslint/template/accessibility-valid-aria': 'error',
'@angular-eslint/template/no-duplicate-attributes': 'error',
}, },
}, },
{ {

2
.gitattributes vendored 100644
View File

@ -0,0 +1,2 @@
* text=auto
*.ts eol=lf

View File

@ -12,7 +12,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: '12.x' node-version: '14.x'
- run: npm ci - run: npm ci
- run: result=$(find src -type f -iname '*.html' -exec sh -c 'cat {} | tr "\n" " " | grep -Eo "class=\"[^\"]+\"[^>]+class=\"" ' \; | wc -l); test $result -eq 0 - run: result=$(find src -type f -iname '*.html' -exec sh -c 'cat {} | tr "\n" " " | grep -Eo "class=\"[^\"]+\"[^>]+class=\"" ' \; | wc -l); test $result -eq 0
- run: npm install -D @ionic/v4-migration-tslint - run: npm install -D @ionic/v4-migration-tslint

View File

@ -0,0 +1,63 @@
name: Performance
on: [push, pull_request]
jobs:
performance:
runs-on: ubuntu-latest
env:
MOODLE_DOCKER_DB: pgsql
MOODLE_DOCKER_BROWSER: chrome
MOODLE_DOCKER_PHP_VERSION: 7.3
steps:
- uses: actions/checkout@v2
- id: nvmrc
uses: browniebroke/read-nvmrc-action@v1
- uses: actions/setup-node@v1
with:
node-version: '${{ steps.nvmrc.outputs.node_version }}'
- name: Additional checkouts
run: |
git clone --branch master --depth 1 https://github.com/moodle/moodle $GITHUB_WORKSPACE/moodle
git clone --branch integration --depth 1 https://github.com/moodlehq/moodle-local_moodlemobileapp $GITHUB_WORKSPACE/moodle/local/moodlemobileapp
git clone --branch master --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker
- name: Install npm packages
run: |
npm install -g npm@7
npm ci --no-audit
- name: Generate Behat tests plugin
run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
npx gulp behat
- name: Configure & launch Moodle with Docker
run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
cp $GITHUB_WORKSPACE/moodle-docker/config.docker-template.php $GITHUB_WORKSPACE/moodle/config.php
sed -i "59i\ 'capabilities' => [" $GITHUB_WORKSPACE/moodle/config.php
sed -i "60i\ 'extra_capabilities' => [" $GITHUB_WORKSPACE/moodle/config.php
sed -i "61i\ 'goog:loggingPrefs' => ['performance' => 'ALL']," $GITHUB_WORKSPACE/moodle/config.php
sed -i "62i\ 'chromeOptions' => ['perfLoggingPrefs' => ['traceCategories' => 'devtools.timeline']]," $GITHUB_WORKSPACE/moodle/config.php
sed -i "63i\ ]," $GITHUB_WORKSPACE/moodle/config.php
sed -i "64i\ ]," $GITHUB_WORKSPACE/moodle/config.php
sed -i "76i\$CFG->behat_ionic_wwwroot = 'http://moodleapp';" $GITHUB_WORKSPACE/moodle/config.php
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose pull
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose up -d
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-wait-for-db
- name: Compile & launch production app with Docker
run: |
docker build -t moodlehq/moodleapp:performance .
docker run -d --rm --name moodleapp moodlehq/moodleapp:performance
docker network connect moodle-docker_default moodleapp --alias moodleapp
- name: Init Behat
run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/init.php"
- name: Run performance tests
run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
for i in {0..2}
do
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/run.php --tags="@performance" --auto-rerun"
done
- name: Show performance results
run: node ./scripts/print-performance-measures.js $GITHUB_WORKSPACE/moodle/behatperformancemeasures/

View File

@ -12,9 +12,11 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: '12.x' node-version: '14'
- name: Install npm packages - name: Install npm packages
run: npm ci run: |
npm install -g npm@7
npm ci --no-audit
- name: Check langindex - name: Check langindex
run: | run: |
result=$(cat scripts/langindex.json | grep \"TBD\" | wc -l); test $result -eq 0 result=$(cat scripts/langindex.json | grep \"TBD\" | wc -l); test $result -eq 0
@ -49,11 +51,11 @@ jobs:
echo "Found $found missing langkeys" echo "Found $found missing langkeys"
exit 1 exit 1
fi fi
- name: Run Linter - name: Run Linter (ignore warnings)
run: npm run lint run: npm run lint -- --quiet
- name: Run tests - name: Run tests
run: npm run test:ci run: npm run test:ci
- name: Production builds - name: Production builds
run: npm run build:prod run: npm run build:prod
- name: JavaScript code compatibility - name: JavaScript code compatibility
run: result=$(npx check-es-compat www/*.js 2> /dev/null | grep -v -E "Array\.prototype\.includes|Promise\.prototype\.finally|String\.prototype\.(matchAll|trimRight)|globalThis" | grep -Po "(?<=error).*?(?=\s+ecmascript)" | wc -l); test $result -eq 0 run: result=$(npx check-es-compat www/*.js 2> /dev/null | grep -v -E "Array\.prototype\.includes|Promise\.prototype\.finally|String\.prototype\.(matchAll|trimRight)|globalThis" | grep -Po "(?<=error).*?(?=\s+ecmascript)" | wc -l); test $result -eq 1

1
.nvmrc 100644
View File

@ -0,0 +1 @@
v14.15.0

View File

@ -1,6 +1,6 @@
os: linux os: linux
dist: trusty dist: trusty
node_js: 12 node_js: 14
git: git:
depth: 3 depth: 3
@ -18,12 +18,12 @@ cache:
- $HOME/.android/build-cache - $HOME/.android/build-cache
before_install: before_install:
- nvm install 12 - nvm install
- npm install npm@^7 -g
- node --version - node --version
- npm --version - npm --version
- nvm --version - nvm --version
- npm ci - npm ci
- npm install npm@^6 -g
before_script: before_script:
- npx gulp - npx gulp
@ -46,16 +46,30 @@ jobs:
- extra-google-google_play_services - extra-google-google_play_services
- extra-google-m2repository - extra-google-m2repository
- extra-android-m2repository - extra-android-m2repository
before_install:
- nvm install
- npm install npm@^7 -g
- node --version
- npm --version
- nvm --version
- npm ci
- yes | sdkmanager "build-tools;30.0.3"
addons: addons:
apt: apt:
packages: packages:
- libsecret-1-dev - libsecret-1-dev
- php5-cli
- php5-common
- stage: build - stage: build
name: "Build iOS" name: "Build iOS"
language: node_js language: node_js
if: env(BUILD_IOS) = 1 AND (env(DEPLOY) = 1 OR (env(DEPLOY) = 2 AND tag IS NOT blank)) if: env(BUILD_IOS) = 1 AND (env(DEPLOY) = 1 OR (env(DEPLOY) = 2 AND tag IS NOT blank))
os: osx os: osx
osx_image: xcode12.5 osx_image: xcode13.1
addons:
homebrew:
packages:
- jq
- stage: test - stage: test
name: "End to end tests (mod_forum and mod_messages)" name: "End to end tests (mod_forum and mod_messages)"
services: services:

5
.vscode/extensions.json vendored 100644
View File

@ -0,0 +1,5 @@
{
"recommendations": [
"dbaeumer.vscode-eslint"
]
}

19
.vscode/settings.json vendored
View File

@ -1,5 +1,24 @@
{ {
/**
* Formatting.
*/
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"[html]": {
"editor.defaultFormatter": "vscode.html-language-features",
},
"editor.formatOnSave": true,
"eslint.format.enable": true,
"html.format.endWithNewline": true,
"html.format.wrapLineLength": 140,
"files.eol": "\n",
"files.trimFinalNewlines": true,
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
/**
* Config files.
*/
"files.associations": { "files.associations": {
"moodle.config.json": "jsonc", "moodle.config.json": "jsonc",
"moodle.config.*.json": "jsonc", "moodle.config.*.json": "jsonc",

View File

@ -6,7 +6,8 @@ WORKDIR /app
# Prepare node dependencies # Prepare node dependencies
RUN apt-get update && apt-get install libsecret-1-0 -y RUN apt-get update && apt-get install libsecret-1-0 -y
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm install -g npm@7
RUN npm ci --no-audit
# Build source # Build source
ARG build_command="npm run build:prod" ARG build_command="npm run build:prod"

View File

@ -1,22 +1,15 @@
Moodle Mobile Moodle App
================= =================
This is the primary repository of source code for the official Moodle Mobile app. This is the primary repository of source code for the official mobile app for Moodle.
* [User documentation](http://docs.moodle.org/en/Moodle_Mobile) * [User documentation](https://docs.moodle.org/en/Moodle_app)
* [Developer documentation](http://docs.moodle.org/dev/Moodle_Mobile) * [Developer documentation](http://docs.moodle.org/dev/Moodle_App)
* [Development environment setup](http://docs.moodle.org/dev/Setting_up_your_development_environment_for_Moodle_Mobile_2) * [Development environment setup](https://docs.moodle.org/dev/Setting_up_your_development_environment_for_the_Moodle_App)
* [Bug Tracker](https://tracker.moodle.org/browse/MOBILE) * [Bug Tracker](https://tracker.moodle.org/browse/MOBILE)
* [Release Notes](http://docs.moodle.org/dev/Moodle_Mobile_Release_Notes) * [Release Notes](https://docs.moodle.org/dev/Moodle_App_Release_Notes)
License License
------- -------
[Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0) [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0)
Big Thanks
-----------
Cross-browser Testing Platform and Open Source <3 Provided by [Sauce Labs](https://saucelabs.com)
![Sauce Labs Logo](https://user-images.githubusercontent.com/557037/43443976-d88d5a78-94a2-11e8-8915-9f06521423dd.png)

View File

@ -12,8 +12,14 @@
"schematics": {}, "schematics": {},
"architect": { "architect": {
"build": { "build": {
"builder": "@angular-devkit/build-angular:browser", "builder": "@angular-builders/custom-webpack:browser",
"options": { "options": {
"customWebpackConfig": {
"path": "./webpack.config.js"
},
"allowedCommonJsDependencies":[
"chart.js"
],
"outputPath": "www", "outputPath": "www",
"index": "src/index.html", "index": "src/index.html",
"main": "src/main.ts", "main": "src/main.ts",
@ -55,11 +61,25 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "50mb", "maximumWarning": "5mb",
"maximumError": "100mb" "maximumError": "20mb"
} }
] ]
}, },
"testing": {
"optimization": {
"scripts": false,
"styles": true
},
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true
},
"ci": { "ci": {
"progress": false "progress": false
} }

View File

@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<widget android-versionCode="39503" id="com.moodle.moodlemobile" ios-CFBundleVersion="3.9.5.3" version="3.9.5" versionCode="39503" xmlns="http://www.w3.org/ns/widgets" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:cdv="http://cordova.apache.org/ns/1.0"> <widget android-versionCode="40001" id="com.moodle.moodlemobile" ios-CFBundleVersion="4.0.0.1" version="4.0.0" versionCode="40001" xmlns="http://www.w3.org/ns/widgets" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<name>Moodle</name> <name>Moodle</name>
<description>Moodle official app</description> <description>Moodle official app</description>
<author email="mobile@moodle.com" href="http://moodle.com">Moodle Mobile team</author> <author email="mobile@moodle.com" href="http://moodle.com">Moodle Mobile team</author>
@ -27,7 +27,7 @@
<preference name="UIWebViewBounce" value="false" /> <preference name="UIWebViewBounce" value="false" />
<preference name="DisallowOverscroll" value="true" /> <preference name="DisallowOverscroll" value="true" />
<preference name="prerendered-icon" value="true" /> <preference name="prerendered-icon" value="true" />
<preference name="AppendUserAgent" value="MoodleMobile" /> <preference name="AppendUserAgent" value="MoodleMobile 4.0.0 (40000)" />
<preference name="BackupWebStorage" value="none" /> <preference name="BackupWebStorage" value="none" />
<preference name="ScrollEnabled" value="false" /> <preference name="ScrollEnabled" value="false" />
<preference name="KeyboardDisplayRequiresUserAction" value="false" /> <preference name="KeyboardDisplayRequiresUserAction" value="false" />
@ -47,6 +47,11 @@
<preference name="iosPersistentFileLocation" value="Compatibility" /> <preference name="iosPersistentFileLocation" value="Compatibility" />
<preference name="iosScheme" value="moodleappfs" /> <preference name="iosScheme" value="moodleappfs" />
<preference name="WKWebViewOnly" value="true" /> <preference name="WKWebViewOnly" value="true" />
<preference name="WKFullScreenEnabled" value="true" />
<preference name="AndroidXEnabled" value="true" />
<preference name="GradlePluginGoogleServicesEnabled" value="true" />
<preference name="GradlePluginGoogleServicesVersion" value="4.3.10" />
<preference name="StatusBarOverlaysWebView" value="false" />
<feature name="StatusBar"> <feature name="StatusBar">
<param name="ios-package" onload="true" value="CDVStatusBar" /> <param name="ios-package" onload="true" value="CDVStatusBar" />
</feature> </feature>
@ -57,11 +62,12 @@
<resource-file src="resources/android/icon/drawable-mdpi-smallicon.png" target="app/src/main/res/mipmap-mdpi/smallicon.png" /> <resource-file src="resources/android/icon/drawable-mdpi-smallicon.png" target="app/src/main/res/mipmap-mdpi/smallicon.png" />
<resource-file src="resources/android/icon/drawable-hdpi-smallicon.png" target="app/src/main/res/mipmap-hdpi/smallicon.png" /> <resource-file src="resources/android/icon/drawable-hdpi-smallicon.png" target="app/src/main/res/mipmap-hdpi/smallicon.png" />
<resource-file src="resources/android/icon/drawable-xhdpi-smallicon.png" target="app/src/main/res/mipmap-xhdpi/smallicon.png" /> <resource-file src="resources/android/icon/drawable-xhdpi-smallicon.png" target="app/src/main/res/mipmap-xhdpi/smallicon.png" />
<resource-file src="resources/android/xml/network_security_config.xml" target="app/src/main/res/xml/network_security_config.xml" />
<edit-config file="AndroidManifest.xml" mode="merge" target="/manifest/application/activity[@android:name='MainActivity']"> <edit-config file="AndroidManifest.xml" mode="merge" target="/manifest/application/activity[@android:name='MainActivity']">
<activity android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|screenLayout|smallestScreenSize" /> <activity android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|screenLayout|smallestScreenSize" />
</edit-config> </edit-config>
<edit-config file="AndroidManifest.xml" mode="merge" target="/manifest/application"> <edit-config file="AndroidManifest.xml" mode="merge" target="/manifest/application">
<application android:largeHeap="true" android:usesCleartextTraffic="true" /> <application android:largeHeap="true" android:networkSecurityConfig="@xml/network_security_config" />
</edit-config> </edit-config>
<config-file parent="/manifest/application" target="AndroidManifest.xml"> <config-file parent="/manifest/application" target="AndroidManifest.xml">
<meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" /> <meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" />
@ -124,11 +130,6 @@
<param name="android-package" value="org.apache.cordova.geolocation.Geolocation" /> <param name="android-package" value="org.apache.cordova.geolocation.Geolocation" />
</feature> </feature>
</config-file> </config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="Globalization">
<param name="android-package" value="org.apache.cordova.globalization.Globalization" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml"> <config-file parent="/*" target="res/xml/config.xml">
<feature name="InAppBrowser"> <feature name="InAppBrowser">
<param name="android-package" value="org.apache.cordova.inappbrowser.InAppBrowser" /> <param name="android-package" value="org.apache.cordova.inappbrowser.InAppBrowser" />
@ -185,12 +186,6 @@
<param name="onload" value="true" /> <param name="onload" value="true" />
</feature> </feature>
</config-file> </config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="Whitelist">
<param name="android-package" value="org.apache.cordova.whitelist.WhitelistPlugin" />
<param name="onload" value="true" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml"> <config-file parent="/*" target="res/xml/config.xml">
<feature name="SQLitePlugin"> <feature name="SQLitePlugin">
<param name="android-package" value="io.sqlc.SQLitePlugin" /> <param name="android-package" value="io.sqlc.SQLitePlugin" />
@ -256,7 +251,7 @@
<true /> <true />
</edit-config> </edit-config>
<edit-config file="*-Info.plist" mode="merge" target="CFBundleShortVersionString"> <edit-config file="*-Info.plist" mode="merge" target="CFBundleShortVersionString">
<string>3.9.5</string> <string>4.0.0</string>
</edit-config> </edit-config>
<edit-config file="*-Info.plist" mode="overwrite" target="CFBundleLocalizations"> <edit-config file="*-Info.plist" mode="overwrite" target="CFBundleLocalizations">
<array> <array>
@ -288,6 +283,9 @@
<config-file parent="NSCrossWebsiteTrackingUsageDescription" target="*-Info.plist"> <config-file parent="NSCrossWebsiteTrackingUsageDescription" target="*-Info.plist">
<string>This app needs third party cookies to correctly render embedded content from the Moodle site.</string> <string>This app needs third party cookies to correctly render embedded content from the Moodle site.</string>
</config-file> </config-file>
<config-file parent="ITSAppUsesNonExemptEncryption" target="*-Info.plist">
<false />
</config-file>
<config-file parent="CFBundleDocumentTypes" target="*-Info.plist"> <config-file parent="CFBundleDocumentTypes" target="*-Info.plist">
<array> <array>
<dict> <dict>

View File

@ -69,3 +69,7 @@ gulp.task('watch', () => {
gulp.watch(['./tests/behat'], { interval: 500 }, gulp.parallel('behat')); gulp.watch(['./tests/behat'], { interval: 500 }, gulp.parallel('behat'));
} }
}); });
gulp.task('watch-behat', () => {
gulp.watch(['./tests/behat'], { interval: 500 }, gulp.parallel('behat'));
});

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
{ {
"app_id": "com.moodle.moodlemobile", "app_id": "com.moodle.moodlemobile",
"appname": "Moodle Mobile", "appname": "Moodle Mobile",
"versioncode": 3950, "versioncode": 40000,
"versionname": "3.9.5", "versionname": "4.0.0",
"cache_update_frequency_usually": 420000, "cache_update_frequency_usually": 420000,
"cache_update_frequency_often": 1200000, "cache_update_frequency_often": 1200000,
"cache_update_frequency_sometimes": 3600000, "cache_update_frequency_sometimes": 3600000,
@ -30,6 +30,7 @@
"he": "עברית", "he": "עברית",
"hi": "हिंदी", "hi": "हिंदी",
"hr": "Hrvatski", "hr": "Hrvatski",
"hsb": "Hornjoserbsski",
"hu": "magyar", "hu": "magyar",
"hy": "Հայերեն", "hy": "Հայերեն",
"id": "Indonesian", "id": "Indonesian",
@ -38,6 +39,7 @@
"km": "ខ្មែរ", "km": "ខ្មែរ",
"kn": "ಕನ್ನಡ", "kn": "ಕನ್ನಡ",
"ko": "한국어", "ko": "한국어",
"lo": "ລາວ",
"lt": "Lietuvių", "lt": "Lietuvių",
"lv": "Latviešu", "lv": "Latviešu",
"mn": "Монгол", "mn": "Монгол",
@ -62,15 +64,14 @@
"zh-tw": "正體中文" "zh-tw": "正體中文"
}, },
"wsservice": "moodle_mobile_app", "wsservice": "moodle_mobile_app",
"wsextservice": "local_mobile",
"demo_sites": { "demo_sites": {
"student": { "student": {
"url": "https:\/\/school.moodledemo.net", "url": "https://school.moodledemo.net",
"username": "student", "username": "student",
"password": "moodle" "password": "moodle"
}, },
"teacher": { "teacher": {
"url": "https:\/\/school.moodledemo.net", "url": "https://school.moodledemo.net",
"username": "teacher", "username": "teacher",
"password": "moodle" "password": "moodle"
} }
@ -88,7 +89,7 @@
"onlyallowlistedsites": false, "onlyallowlistedsites": false,
"skipssoconfirmation": false, "skipssoconfirmation": false,
"forcedefaultlanguage": false, "forcedefaultlanguage": false,
"privacypolicy": "https:\/\/moodle.net\/moodle-app-privacy\/", "privacypolicy": "https://moodle.net/moodle-app-privacy/",
"notificoncolor": "#f98012", "notificoncolor": "#f98012",
"enableanalytics": false, "enableanalytics": false,
"enableonboarding": true, "enableonboarding": true,
@ -98,5 +99,8 @@
"appstores": { "appstores": {
"android": "com.moodle.moodlemobile", "android": "com.moodle.moodlemobile",
"ios": "id633359593" "ios": "id633359593"
} },
"wsrequestqueuelimit": 10,
"wsrequestqueuedelay": 100,
"calendarreminderdefaultvalue": 3600
} }

34615
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "moodlemobile", "name": "moodlemobile",
"version": "3.9.5", "version": "4.0.0",
"description": "The official app for Moodle.", "description": "The official app for Moodle.",
"author": { "author": {
"name": "Moodle Pty Ltd.", "name": "Moodle Pty Ltd.",
@ -8,7 +8,7 @@
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/moodlehq/moodlemobile2.git" "url": "https://github.com/moodlehq/moodleapp.git"
}, },
"license": "Apache-2.0", "license": "Apache-2.0",
"licenses": [ "licenses": [
@ -19,21 +19,22 @@
], ],
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ionic serve", "start": "ionic serve --browser=$MOODLE_APP_BROWSER",
"serve:test": "NODE_ENV=testing ionic serve --no-open",
"build": "ionic build", "build": "ionic build",
"build:prod": "NODE_ENV=production ionic build --prod", "build:prod": "NODE_ENV=production ionic build --prod",
"build:test": "NODE_ENV=testing ionic build", "build:test": "NODE_ENV=testing ionic build --configuration=testing",
"dev:android": "ionic cordova run android --livereload", "dev:android": "ionic cordova run android --livereload",
"dev:ios": "ionic cordova run ios --livereload", "dev:ios": "ionic cordova run ios",
"prod:android": "NODE_ENV=production ionic cordova run android --aot", "prod:android": "NODE_ENV=production ionic cordova run android --prod",
"prod:ios": "NODE_ENV=production ionic cordova run ios --aot", "prod:ios": "NODE_ENV=production ionic cordova run ios --prod",
"test": "NODE_ENV=testing gulp && jest --verbose", "test": "NODE_ENV=testing gulp && jest --verbose",
"test:ci": "NODE_ENV=testing gulp && jest -ci --runInBand --verbose", "test:ci": "NODE_ENV=testing gulp && jest -ci --runInBand --verbose",
"test:watch": "NODE_ENV=testing gulp watch & jest --watch", "test:watch": "NODE_ENV=testing gulp watch & jest --watch",
"test:coverage": "NODE_ENV=testing gulp && jest --coverage", "test:coverage": "NODE_ENV=testing gulp && jest --coverage",
"lint": "NODE_OPTIONS=--max-old-space-size=4096 ng lint", "lint": "NODE_OPTIONS=--max-old-space-size=4096 ng lint",
"ionic:serve:before": "gulp", "ionic:serve:before": "gulp",
"ionic:serve": "gulp watch & NODE_OPTIONS=--max-old-space-size=4096 ng serve", "ionic:serve": "cross-env-shell ./scripts/serve.sh",
"ionic:build:before": "gulp" "ionic:build:before": "gulp"
}, },
"dependencies": { "dependencies": {
@ -70,65 +71,61 @@
"@ionic-native/status-bar": "5.33.0", "@ionic-native/status-bar": "5.33.0",
"@ionic-native/web-intent": "5.33.0", "@ionic-native/web-intent": "5.33.0",
"@ionic-native/zip": "5.33.0", "@ionic-native/zip": "5.33.0",
"@ionic/angular": "5.6.6", "@ionic/angular": "5.9.2",
"@moodlehq/cordova-plugin-file-transfer": "1.7.1-moodle.5",
"@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3",
"@moodlehq/cordova-plugin-ionic-webview": "5.0.0-moodle.1",
"@moodlehq/cordova-plugin-local-notification": "0.9.0-moodle.3",
"@moodlehq/cordova-plugin-qrscanner": "3.0.1-moodle.2",
"@moodlehq/cordova-plugin-zip": "3.1.0-moodle.1",
"@moodlehq/phonegap-plugin-push": "2.0.0-moodle.4",
"@ngx-translate/core": "13.0.0", "@ngx-translate/core": "13.0.0",
"@ngx-translate/http-loader": "6.0.0", "@ngx-translate/http-loader": "6.0.0",
"@types/chart.js": "2.9.31", "@types/chart.js": "2.9.31",
"@types/cordova": "0.0.34", "@types/cordova": "0.0.34",
"@types/cordova-plugin-file-transfer": "1.6.2",
"@types/dom-mediacapture-record": "1.0.7", "@types/dom-mediacapture-record": "1.0.7",
"chart.js": "2.9.4", "chart.js": "2.9.4",
"com-darryncampbell-cordova-plugin-intent": "1.3.0", "com-darryncampbell-cordova-plugin-intent": "2.2.0",
"cordova": "10.0.0", "cordova": "11.0.0",
"cordova-android": "9.1.0", "cordova-android": "10.1.1",
"cordova-android-support-gradle-release": "3.0.1",
"cordova-clipboard": "1.3.0", "cordova-clipboard": "1.3.0",
"cordova-ios": "6.2.0", "cordova-ios": "6.2.0",
"cordova-plugin-add-swift-support": "2.0.2", "cordova-plugin-add-swift-support": "2.0.2",
"cordova-plugin-advanced-http": "3.1.0", "cordova-plugin-advanced-http": "3.2.2",
"cordova-plugin-badge": "0.8.8", "cordova-plugin-badge": "0.8.8",
"cordova-plugin-camera": "5.0.1", "cordova-plugin-camera": "6.0.0",
"cordova-plugin-chooser": "1.3.2", "cordova-plugin-chooser": "1.3.2",
"cordova-plugin-customurlscheme": "5.0.2", "cordova-plugin-customurlscheme": "5.0.2",
"cordova-plugin-device": "2.0.3", "cordova-plugin-device": "2.0.3",
"cordova-plugin-file": "6.0.2", "cordova-plugin-file": "6.0.2",
"cordova-plugin-file-opener2": "3.0.5", "cordova-plugin-file-opener2": "3.0.5",
"cordova-plugin-file-transfer": "git+https://github.com/moodlemobile/cordova-plugin-file-transfer.git",
"cordova-plugin-geolocation": "4.1.0", "cordova-plugin-geolocation": "4.1.0",
"cordova-plugin-globalization": "1.11.0",
"cordova-plugin-inappbrowser": "git+https://github.com/moodlemobile/cordova-plugin-inappbrowser.git#moodle-ionic5",
"cordova-plugin-ionic-keyboard": "2.2.0", "cordova-plugin-ionic-keyboard": "2.2.0",
"cordova-plugin-ionic-webview": "5.0.0", "cordova-plugin-media": "5.0.4",
"cordova-plugin-local-notification": "git+https://github.com/moodlemobile/cordova-plugin-local-notification.git#moodle",
"cordova-plugin-media": "5.0.3",
"cordova-plugin-media-capture": "3.0.3", "cordova-plugin-media-capture": "3.0.3",
"cordova-plugin-network-information": "2.0.2", "cordova-plugin-network-information": "3.0.0",
"cordova-plugin-qrscanner": "git+https://github.com/moodlemobile/cordova-plugin-qrscanner.git#dist", "cordova-plugin-prevent-override": "1.0.1",
"cordova-plugin-screen-orientation": "3.0.2",
"cordova-plugin-splashscreen": "6.0.0", "cordova-plugin-splashscreen": "6.0.0",
"cordova-plugin-statusbar": "2.4.3", "cordova-plugin-statusbar": "3.0.0",
"cordova-plugin-whitelist": "1.3.4", "cordova-plugin-wkuserscript": "1.0.1",
"cordova-plugin-wkuserscript": "git+https://github.com/moodlemobile/cordova-plugin-wkuserscript.git", "cordova-plugin-wkwebview-cookies": "1.0.1",
"cordova-plugin-wkwebview-cookies": "git+https://github.com/moodlemobile/cordova-plugin-wkwebview-cookies.git",
"cordova-plugin-zip": "3.1.0",
"cordova-sqlite-storage": "6.0.0", "cordova-sqlite-storage": "6.0.0",
"cordova-support-google-services": "1.3.2", "cordova.plugins.diagnostic": "6.1.1",
"cordova.plugins.diagnostic": "5.0.2",
"core-js": "3.9.1", "core-js": "3.9.1",
"es6-promise-plugin": "4.2.2", "es6-promise-plugin": "4.2.2",
"jszip": "3.5.0", "hammerjs": "2.0.8",
"jszip": "3.7.1",
"mathjax": "2.7.7", "mathjax": "2.7.7",
"moment": "2.29.0", "moment": "2.29.2",
"nl.kingsquare.cordova.background-audio": "1.0.1", "nl.kingsquare.cordova.background-audio": "1.0.1",
"phonegap-plugin-multidex": "1.0.0",
"phonegap-plugin-push": "git+https://github.com/moodlemobile/phonegap-plugin-push.git#moodle-v3",
"rxjs": "6.5.5", "rxjs": "6.5.5",
"ts-md5": "1.2.7", "ts-md5": "1.2.7",
"tslib": "2.0.1", "tslib": "2.3.1",
"zone.js": "0.10.3" "zone.js": "0.10.3"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/architect": "0.1101.2", "@angular-builders/custom-webpack": "10.0.1",
"@angular-devkit/architect": "0.1202.7",
"@angular-devkit/build-angular": "0.1000.8", "@angular-devkit/build-angular": "0.1000.8",
"@angular-eslint/builder": "4.2.0", "@angular-eslint/builder": "4.2.0",
"@angular-eslint/eslint-plugin": "4.2.0", "@angular-eslint/eslint-plugin": "4.2.0",
@ -140,7 +137,7 @@
"@angular/compiler-cli": "10.0.14", "@angular/compiler-cli": "10.0.14",
"@angular/language-service": "10.0.14", "@angular/language-service": "10.0.14",
"@ionic/angular-toolkit": "2.3.3", "@ionic/angular-toolkit": "2.3.3",
"@ionic/cli": "6.14.1", "@ionic/cli": "6.19.0",
"@types/faker": "5.1.3", "@types/faker": "5.1.3",
"@types/node": "12.12.64", "@types/node": "12.12.64",
"@types/resize-observer-browser": "0.1.5", "@types/resize-observer-browser": "0.1.5",
@ -148,7 +145,9 @@
"@typescript-eslint/eslint-plugin": "4.22.0", "@typescript-eslint/eslint-plugin": "4.22.0",
"@typescript-eslint/parser": "4.22.0", "@typescript-eslint/parser": "4.22.0",
"check-es-compat": "1.1.1", "check-es-compat": "1.1.1",
"cordova-plugin-prevent-override": "git+https://github.com/moodlemobile/cordova-plugin-prevent-override.git", "cordova-plugin-androidx-adapter": "1.1.3",
"cordova-plugin-screen-orientation": "^3.0.2",
"cross-env": "7.0.3",
"eslint": "7.25.0", "eslint": "7.25.0",
"eslint-config-prettier": "8.3.0", "eslint-config-prettier": "8.3.0",
"eslint-plugin-header": "3.1.1", "eslint-plugin-header": "3.1.1",
@ -169,13 +168,14 @@
"jest": "26.5.2", "jest": "26.5.2",
"jest-preset-angular": "8.3.1", "jest-preset-angular": "8.3.1",
"jsonc-parser": "2.3.1", "jsonc-parser": "2.3.1",
"native-run": "^1.4.0", "native-run": "1.4.0",
"terser-webpack-plugin": "4.2.3",
"ts-jest": "26.4.1", "ts-jest": "26.4.1",
"ts-node": "8.3.0", "ts-node": "8.3.0",
"typescript": "3.9.9" "typescript": "3.9.9"
}, },
"engines": { "engines": {
"node": ">=12.x" "node": ">=14.15.0 <15"
}, },
"cordova": { "cordova": {
"platforms": [ "platforms": [
@ -183,11 +183,14 @@
"ios" "ios"
], ],
"plugins": { "plugins": {
"cordova-plugin-advanced-http": {}, "cordova-plugin-advanced-http": {
"ANDROIDBLACKLISTSECURESOCKETPROTOCOLS": "SSLv3,TLSv1"
},
"cordova-clipboard": {}, "cordova-clipboard": {},
"cordova-plugin-badge": {}, "cordova-plugin-badge": {},
"cordova-plugin-camera": { "cordova-plugin-camera": {
"ANDROID_SUPPORT_V4_VERSION": "27.+" "ANDROID_SUPPORT_V4_VERSION": "27.+",
"ANDROIDX_CORE_VERSION": "1.6.+"
}, },
"cordova-plugin-chooser": {}, "cordova-plugin-chooser": {},
"cordova-plugin-customurlscheme": { "cordova-plugin-customurlscheme": {
@ -203,10 +206,10 @@
"cordova-plugin-geolocation": { "cordova-plugin-geolocation": {
"GPS_REQUIRED": "false" "GPS_REQUIRED": "false"
}, },
"cordova-plugin-inappbrowser": {}, "@moodlehq/cordova-plugin-inappbrowser": {},
"cordova-plugin-ionic-keyboard": {}, "cordova-plugin-ionic-keyboard": {},
"cordova-plugin-ionic-webview": {}, "@moodlehq/cordova-plugin-ionic-webview": {},
"cordova-plugin-local-notification": { "@moodlehq/cordova-plugin-local-notification": {
"ANDROID_SUPPORT_V4_VERSION": "26.+" "ANDROID_SUPPORT_V4_VERSION": "26.+"
}, },
"cordova-plugin-media-capture": {}, "cordova-plugin-media-capture": {},
@ -214,30 +217,29 @@
"KEEP_AVAUDIOSESSION_ALWAYS_ACTIVE": "NO" "KEEP_AVAUDIOSESSION_ALWAYS_ACTIVE": "NO"
}, },
"cordova-plugin-network-information": {}, "cordova-plugin-network-information": {},
"cordova-plugin-qrscanner": {}, "@moodlehq/cordova-plugin-qrscanner": {},
"cordova-plugin-screen-orientation": {},
"cordova-plugin-splashscreen": {}, "cordova-plugin-splashscreen": {},
"cordova-plugin-statusbar": {}, "cordova-plugin-statusbar": {},
"cordova-plugin-whitelist": {},
"cordova-plugin-wkuserscript": {}, "cordova-plugin-wkuserscript": {},
"cordova-plugin-wkwebview-cookies": {}, "cordova-plugin-wkwebview-cookies": {},
"cordova-plugin-zip": {}, "@moodlehq/cordova-plugin-zip": {},
"cordova-sqlite-storage": {}, "cordova-sqlite-storage": {},
"phonegap-plugin-push": { "@moodlehq/phonegap-plugin-push": {
"ANDROID_SUPPORT_V13_VERSION": "27.+", "ANDROID_SUPPORT_V13_VERSION": "28.0.0",
"FCM_VERSION": "17.0.+" "FCM_VERSION": "18.+",
"IOS_FIREBASE_MESSAGING_VERSION": "~> 6.32.2"
}, },
"com-darryncampbell-cordova-plugin-intent": {}, "com-darryncampbell-cordova-plugin-intent": {},
"nl.kingsquare.cordova.background-audio": {}, "nl.kingsquare.cordova.background-audio": {},
"cordova-android-support-gradle-release": {
"ANDROID_SUPPORT_VERSION": "27.+"
},
"cordova.plugins.diagnostic": { "cordova.plugins.diagnostic": {
"ANDROID_SUPPORT_VERSION": "28.+" "ANDROID_SUPPORT_VERSION": "28.+",
"ANDROIDX_VERSION": "1.0.0",
"ANDROIDX_APPCOMPAT_VERSION": "1.3.1"
}, },
"cordova-plugin-globalization": {}, "@moodlehq/cordova-plugin-file-transfer": {},
"cordova-plugin-file-transfer": {}, "cordova-plugin-prevent-override": {},
"cordova-plugin-prevent-override": {} "cordova-plugin-androidx-adapter": {},
"cordova-plugin-screen-orientation": {}
} }
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@ -1,6 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<network-security-config> <network-security-config>
<domain-config cleartextTrafficPermitted="true"> <base-config cleartextTrafficPermitted="true" />
<domain includeSubdomains="true">localhost</domain> </network-security-config>
</domain-config>
</network-security-config>

View File

@ -113,10 +113,17 @@ function add_langs_to_config($langs, $config) {
$config['languages'] = json_decode( json_encode( $config['languages'] ), true ); $config['languages'] = json_decode( json_encode( $config['languages'] ), true );
ksort($config['languages']); ksort($config['languages']);
$config['languages'] = json_decode( json_encode( $config['languages'] ), false ); $config['languages'] = json_decode( json_encode( $config['languages'] ), false );
file_put_contents(CONFIG, json_encode($config, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); save_json(CONFIG, $config);
} }
} }
/**
* Save json data.
*/
function save_json($path, $content) {
file_put_contents($path, str_replace('\/', '/', json_encode($content, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT))."\n");
}
function get_langfolder($lang) { function get_langfolder($lang) {
$folder = LANGPACKSFOLDER.'/'.str_replace('-', '_', $lang); $folder = LANGPACKSFOLDER.'/'.str_replace('-', '_', $lang);
if (!is_dir($folder) || !is_file($folder.'/langconfig.php')) { if (!is_dir($folder) || !is_file($folder.'/langconfig.php')) {
@ -246,9 +253,11 @@ function build_lang($lang, $keys) {
} }
if ($value->file != 'local_moodlemobileapp') { if ($value->file != 'local_moodlemobileapp') {
$text = str_replace('$a->@', '$a.', $text);
$text = str_replace('$a->', '$a.', $text); $text = str_replace('$a->', '$a.', $text);
$text = str_replace('{$a', '{{$a', $text); $text = str_replace('{$a', '{{$a', $text);
$text = str_replace('}', '}}', $text); $text = str_replace('}', '}}', $text);
$text = preg_replace('/@@.+?@@(<br>)?\\s*/', '', $text);
// Prevent double. // Prevent double.
$text = str_replace(array('{{{', '}}}'), array('{{', '}}'), $text); $text = str_replace(array('{{{', '}}}'), array('{{', '}}'), $text);
} else { } else {
@ -270,7 +279,7 @@ function build_lang($lang, $keys) {
// Sort and save. // Sort and save.
ksort($translations); ksort($translations);
file_put_contents(ASSETSPATH.$lang.'.json', str_replace('\/', '/', json_encode($translations, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT))); save_json(ASSETSPATH.$lang.'.json', $translations);
$success = count($translations); $success = count($translations);
$percentage = floor($success/$total * 100); $percentage = floor($success/$total * 100);
@ -365,7 +374,7 @@ function save_key($key, $value, $filePath) {
if (!isset($file[$key]) || $file[$key] != $value) { if (!isset($file[$key]) || $file[$key] != $value) {
$file[$key] = $value; $file[$key] = $value;
ksort($file); ksort($file);
file_put_contents($filePath, str_replace('\/', '/', json_encode($file, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT))); save_json($filePath, $file);
} }
} }

View File

@ -102,6 +102,15 @@ function get_language {
pushd $LANGPACKSFOLDER > /dev/null pushd $LANGPACKSFOLDER > /dev/null
curl -s $MOODLEORG_URL/$lastversion/$lang.zip --output $lang.zip > /dev/null curl -s $MOODLEORG_URL/$lastversion/$lang.zip --output $lang.zip > /dev/null
size=$(du -k "$lang.zip" | cut -f 1)
if [ ! -n $lang.zip ] || [ $size -le 60 ]; then
echo "Wrong language name or corrupt file for $lang"
rm $lang.zip
popd > /dev/null
return
fi
rm -R $lang > /dev/null 2>&1> /dev/null rm -R $lang > /dev/null 2>&1> /dev/null
unzip -o -u $lang.zip > /dev/null unzip -o -u $lang.zip > /dev/null
@ -114,6 +123,11 @@ function get_language {
# Entry function to get all language files. # Entry function to get all language files.
function get_languages { function get_languages {
suffix=$1
if [ -z $suffix ]; then
suffix=''
fi
get_last_version get_last_version
if [ -d $LANGPACKSFOLDER ]; then if [ -d $LANGPACKSFOLDER ]; then
@ -131,6 +145,7 @@ function get_languages {
if [ $AWS_SERVICE -eq 1 ]; then if [ $AWS_SERVICE -eq 1 ]; then
get_all_languages_aws get_all_languages_aws
suffix=''
else else
echo "Fallback language list will only get current installation languages" echo "Fallback language list will only get current installation languages"
get_installed_languages get_installed_languages
@ -138,5 +153,9 @@ function get_languages {
for lang in $langs; do for lang in $langs; do
get_language "$lang" get_language "$lang"
if [ $suffix != '' ]; then
get_language "$lang$suffix"
fi
done done
} }

View File

@ -40,12 +40,18 @@
"addon.block_learningplans.pluginname": "block_lp", "addon.block_learningplans.pluginname": "block_lp",
"addon.block_myoverview.all": "block_myoverview", "addon.block_myoverview.all": "block_myoverview",
"addon.block_myoverview.allincludinghidden": "block_myoverview", "addon.block_myoverview.allincludinghidden": "block_myoverview",
"addon.block_myoverview.browseallcourses": "local_moodlemobileapp",
"addon.block_myoverview.card": "block_myoverview",
"addon.block_myoverview.favourites": "block_myoverview", "addon.block_myoverview.favourites": "block_myoverview",
"addon.block_myoverview.future": "block_myoverview", "addon.block_myoverview.future": "block_myoverview",
"addon.block_myoverview.hiddencourses": "block_myoverview", "addon.block_myoverview.hiddencourses": "block_myoverview",
"addon.block_myoverview.inprogress": "block_myoverview", "addon.block_myoverview.inprogress": "block_myoverview",
"addon.block_myoverview.lastaccessed": "block_myoverview", "addon.block_myoverview.lastaccessed": "block_myoverview",
"addon.block_myoverview.nocourses": "block_myoverview", "addon.block_myoverview.list": "block_myoverview",
"addon.block_myoverview.nocoursesenrolled": "local_moodlemobileapp",
"addon.block_myoverview.nocoursesenrolleddescription": "local_moodlemobileapp",
"addon.block_myoverview.noresult": "local_moodlemobileapp",
"addon.block_myoverview.noresultdescription": "local_moodlemobileapp",
"addon.block_myoverview.past": "block_myoverview", "addon.block_myoverview.past": "block_myoverview",
"addon.block_myoverview.pluginname": "block_myoverview", "addon.block_myoverview.pluginname": "block_myoverview",
"addon.block_myoverview.shortname": "block_myoverview", "addon.block_myoverview.shortname": "block_myoverview",
@ -73,6 +79,7 @@
"addon.block_timeline.noevents": "block_timeline", "addon.block_timeline.noevents": "block_timeline",
"addon.block_timeline.overdue": "block_timeline", "addon.block_timeline.overdue": "block_timeline",
"addon.block_timeline.pluginname": "block_timeline", "addon.block_timeline.pluginname": "block_timeline",
"addon.block_timeline.searchevents": "block_timeline",
"addon.block_timeline.sortbycourses": "block_timeline", "addon.block_timeline.sortbycourses": "block_timeline",
"addon.block_timeline.sortbydates": "block_timeline", "addon.block_timeline.sortbydates": "block_timeline",
"addon.blog.blog": "blog", "addon.blog.blog": "blog",
@ -140,6 +147,7 @@
"addon.calendar.sunday": "calendar", "addon.calendar.sunday": "calendar",
"addon.calendar.thu": "calendar", "addon.calendar.thu": "calendar",
"addon.calendar.thursday": "calendar", "addon.calendar.thursday": "calendar",
"addon.calendar.timebefore": "local_moodlemobileapp",
"addon.calendar.today": "calendar", "addon.calendar.today": "calendar",
"addon.calendar.tomorrow": "calendar", "addon.calendar.tomorrow": "calendar",
"addon.calendar.tue": "calendar", "addon.calendar.tue": "calendar",
@ -153,6 +161,7 @@
"addon.calendar.typeopen": "calendar", "addon.calendar.typeopen": "calendar",
"addon.calendar.typesite": "calendar", "addon.calendar.typesite": "calendar",
"addon.calendar.typeuser": "calendar", "addon.calendar.typeuser": "calendar",
"addon.calendar.units": "qtype_numerical",
"addon.calendar.upcomingevents": "calendar", "addon.calendar.upcomingevents": "calendar",
"addon.calendar.userevents": "calendar", "addon.calendar.userevents": "calendar",
"addon.calendar.wed": "calendar", "addon.calendar.wed": "calendar",
@ -229,6 +238,7 @@
"addon.coursecompletion.status": "moodle", "addon.coursecompletion.status": "moodle",
"addon.coursecompletion.viewcoursereport": "completion", "addon.coursecompletion.viewcoursereport": "completion",
"addon.messageoutput_airnotifier.processorsettingsdesc": "local_moodlemobileapp", "addon.messageoutput_airnotifier.processorsettingsdesc": "local_moodlemobileapp",
"addon.messageoutput_airnotifier.pushdisabledwarning": "local_moodlemobileapp",
"addon.messages.acceptandaddcontact": "message", "addon.messages.acceptandaddcontact": "message",
"addon.messages.addcontact": "message", "addon.messages.addcontact": "message",
"addon.messages.addcontactconfirm": "message", "addon.messages.addcontactconfirm": "message",
@ -325,14 +335,18 @@
"addon.mod_assign.allowsubmissionsfromdatesummary": "assign", "addon.mod_assign.allowsubmissionsfromdatesummary": "assign",
"addon.mod_assign.applytoteam": "assign", "addon.mod_assign.applytoteam": "assign",
"addon.mod_assign.assignmentisdue": "assign", "addon.mod_assign.assignmentisdue": "assign",
"addon.mod_assign.assigntimeleft": "assign",
"addon.mod_assign.attemptnumber": "assign", "addon.mod_assign.attemptnumber": "assign",
"addon.mod_assign.attemptreopenmethod": "assign", "addon.mod_assign.attemptreopenmethod": "assign",
"addon.mod_assign.attemptreopenmethod_manual": "assign", "addon.mod_assign.attemptreopenmethod_manual": "assign",
"addon.mod_assign.attemptreopenmethod_untilpass": "assign", "addon.mod_assign.attemptreopenmethod_untilpass": "assign",
"addon.mod_assign.attemptsettings": "assign", "addon.mod_assign.attemptsettings": "assign",
"addon.mod_assign.beginassignment": "assign",
"addon.mod_assign.caneditsubmission": "assign",
"addon.mod_assign.cannoteditduetostatementsubmission": "local_moodlemobileapp", "addon.mod_assign.cannoteditduetostatementsubmission": "local_moodlemobileapp",
"addon.mod_assign.cannotgradefromapp": "local_moodlemobileapp", "addon.mod_assign.cannotgradefromapp": "local_moodlemobileapp",
"addon.mod_assign.cannotsubmitduetostatementsubmission": "local_moodlemobileapp", "addon.mod_assign.cannotsubmitduetostatementsubmission": "local_moodlemobileapp",
"addon.mod_assign.confirmstart": "assign",
"addon.mod_assign.confirmsubmission": "assign", "addon.mod_assign.confirmsubmission": "assign",
"addon.mod_assign.currentattempt": "assign", "addon.mod_assign.currentattempt": "assign",
"addon.mod_assign.currentattemptof": "assign", "addon.mod_assign.currentattemptof": "assign",
@ -340,7 +354,7 @@
"addon.mod_assign.cutoffdate": "assign", "addon.mod_assign.cutoffdate": "assign",
"addon.mod_assign.defaultteam": "assign", "addon.mod_assign.defaultteam": "assign",
"addon.mod_assign.duedate": "assign", "addon.mod_assign.duedate": "assign",
"addon.mod_assign.duedateno": "assign", "addon.mod_assign.duedateno": "local_moodlemobileapp",
"addon.mod_assign.duedatereached": "assign", "addon.mod_assign.duedatereached": "assign",
"addon.mod_assign.editingstatus": "assign", "addon.mod_assign.editingstatus": "assign",
"addon.mod_assign.editsubmission": "assign", "addon.mod_assign.editsubmission": "assign",
@ -410,7 +424,10 @@
"addon.mod_assign.submitassignment_help": "assign", "addon.mod_assign.submitassignment_help": "assign",
"addon.mod_assign.submittedearly": "assign", "addon.mod_assign.submittedearly": "assign",
"addon.mod_assign.submittedlate": "assign", "addon.mod_assign.submittedlate": "assign",
"addon.mod_assign.submittedovertime": "assign",
"addon.mod_assign.submittedundertime": "assign",
"addon.mod_assign.syncblockedusercomponent": "local_moodlemobileapp", "addon.mod_assign.syncblockedusercomponent": "local_moodlemobileapp",
"addon.mod_assign.timelimit": "assign",
"addon.mod_assign.timemodified": "assign", "addon.mod_assign.timemodified": "assign",
"addon.mod_assign.timeremaining": "assign", "addon.mod_assign.timeremaining": "assign",
"addon.mod_assign.ungroupedusers": "assign", "addon.mod_assign.ungroupedusers": "assign",
@ -428,6 +445,23 @@
"addon.mod_assign_submission_file.pluginname": "assignsubmission_file", "addon.mod_assign_submission_file.pluginname": "assignsubmission_file",
"addon.mod_assign_submission_onlinetext.pluginname": "assignsubmission_onlinetext", "addon.mod_assign_submission_onlinetext.pluginname": "assignsubmission_onlinetext",
"addon.mod_assign_submission_onlinetext.wordlimitexceeded": "assignsubmission_onlinetext", "addon.mod_assign_submission_onlinetext.wordlimitexceeded": "assignsubmission_onlinetext",
"addon.mod_bigbluebuttonbn.end_session_confirm": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.end_session_confirm_title": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.mod_form_field_closingtime": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.mod_form_field_openingtime": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.userlimitreached": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_conference_action_end": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_conference_action_join": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_error_unable_join_student": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_groups_selection_warning": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_message_conference_in_progress": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_message_conference_room_ready": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_message_moderator": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_message_moderators": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_message_session_started_at": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_message_viewer": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_message_viewers": "bigbluebuttonbn",
"addon.mod_bigbluebuttonbn.view_nojoin": "bigbluebuttonbn",
"addon.mod_book.errorchapter": "book", "addon.mod_book.errorchapter": "book",
"addon.mod_book.modulenameplural": "book", "addon.mod_book.modulenameplural": "book",
"addon.mod_book.navnexttitle": "book", "addon.mod_book.navnexttitle": "book",
@ -538,6 +572,7 @@
"addon.mod_feedback.analysis": "feedback", "addon.mod_feedback.analysis": "feedback",
"addon.mod_feedback.anonymous": "feedback", "addon.mod_feedback.anonymous": "feedback",
"addon.mod_feedback.anonymous_entries": "feedback", "addon.mod_feedback.anonymous_entries": "feedback",
"addon.mod_feedback.anonymous_user": "feedback",
"addon.mod_feedback.average": "feedback", "addon.mod_feedback.average": "feedback",
"addon.mod_feedback.captchaofflinewarning": "local_moodlemobileapp", "addon.mod_feedback.captchaofflinewarning": "local_moodlemobileapp",
"addon.mod_feedback.complete_the_form": "feedback", "addon.mod_feedback.complete_the_form": "feedback",
@ -553,7 +588,6 @@
"addon.mod_feedback.minimal": "feedback", "addon.mod_feedback.minimal": "feedback",
"addon.mod_feedback.mode": "feedback", "addon.mod_feedback.mode": "feedback",
"addon.mod_feedback.modulenameplural": "feedback", "addon.mod_feedback.modulenameplural": "feedback",
"addon.mod_feedback.next_page": "feedback",
"addon.mod_feedback.non_anonymous": "feedback", "addon.mod_feedback.non_anonymous": "feedback",
"addon.mod_feedback.non_anonymous_entries": "feedback", "addon.mod_feedback.non_anonymous_entries": "feedback",
"addon.mod_feedback.non_respondents_students": "feedback", "addon.mod_feedback.non_respondents_students": "feedback",
@ -563,7 +597,6 @@
"addon.mod_feedback.overview": "feedback", "addon.mod_feedback.overview": "feedback",
"addon.mod_feedback.page_after_submit": "feedback", "addon.mod_feedback.page_after_submit": "feedback",
"addon.mod_feedback.preview": "moodle", "addon.mod_feedback.preview": "moodle",
"addon.mod_feedback.previous_page": "feedback",
"addon.mod_feedback.questions": "feedback", "addon.mod_feedback.questions": "feedback",
"addon.mod_feedback.questionscountdescription": "local_moodlemobileapp", "addon.mod_feedback.questionscountdescription": "local_moodlemobileapp",
"addon.mod_feedback.response_nr": "feedback", "addon.mod_feedback.response_nr": "feedback",
@ -625,8 +658,8 @@
"addon.mod_forum.posttoforum": "forum", "addon.mod_forum.posttoforum": "forum",
"addon.mod_forum.posttomygroups": "forum", "addon.mod_forum.posttomygroups": "forum",
"addon.mod_forum.privatereply": "forum", "addon.mod_forum.privatereply": "forum",
"addon.mod_forum.qandanotify": "forum",
"addon.mod_forum.re": "forum", "addon.mod_forum.re": "forum",
"addon.mod_forum.refreshdiscussions": "local_moodlemobileapp",
"addon.mod_forum.refreshposts": "local_moodlemobileapp", "addon.mod_forum.refreshposts": "local_moodlemobileapp",
"addon.mod_forum.removefromfavourites": "forum", "addon.mod_forum.removefromfavourites": "forum",
"addon.mod_forum.reply": "forum", "addon.mod_forum.reply": "forum",
@ -681,7 +714,9 @@
"addon.mod_h5pactivity.attempt_success_fail": "h5pactivity", "addon.mod_h5pactivity.attempt_success_fail": "h5pactivity",
"addon.mod_h5pactivity.attempt_success_pass": "h5pactivity", "addon.mod_h5pactivity.attempt_success_pass": "h5pactivity",
"addon.mod_h5pactivity.attempt_success_unknown": "h5pactivity", "addon.mod_h5pactivity.attempt_success_unknown": "h5pactivity",
"addon.mod_h5pactivity.attempts": "h5pactivity",
"addon.mod_h5pactivity.attempts_none": "h5pactivity", "addon.mod_h5pactivity.attempts_none": "h5pactivity",
"addon.mod_h5pactivity.attempts_report": "h5pactivity",
"addon.mod_h5pactivity.completion": "h5pactivity", "addon.mod_h5pactivity.completion": "h5pactivity",
"addon.mod_h5pactivity.downloadh5pfile": "local_moodlemobileapp", "addon.mod_h5pactivity.downloadh5pfile": "local_moodlemobileapp",
"addon.mod_h5pactivity.duration": "h5pactivity", "addon.mod_h5pactivity.duration": "h5pactivity",
@ -692,12 +727,13 @@
"addon.mod_h5pactivity.modulenameplural": "h5pactivity", "addon.mod_h5pactivity.modulenameplural": "h5pactivity",
"addon.mod_h5pactivity.myattempts": "h5pactivity", "addon.mod_h5pactivity.myattempts": "h5pactivity",
"addon.mod_h5pactivity.no_compatible_track": "h5pactivity", "addon.mod_h5pactivity.no_compatible_track": "h5pactivity",
"addon.mod_h5pactivity.noparticipants": "h5pactivity",
"addon.mod_h5pactivity.offlinedisabledwarning": "local_moodlemobileapp", "addon.mod_h5pactivity.offlinedisabledwarning": "local_moodlemobileapp",
"addon.mod_h5pactivity.outcome": "h5pactivity", "addon.mod_h5pactivity.outcome": "h5pactivity",
"addon.mod_h5pactivity.previewmode": "h5pactivity", "addon.mod_h5pactivity.previewmode": "h5pactivity",
"addon.mod_h5pactivity.result_fill-in": "h5pactivity", "addon.mod_h5pactivity.result_fill-in": "h5pactivity",
"addon.mod_h5pactivity.result_other": "h5pactivity", "addon.mod_h5pactivity.result_other": "h5pactivity",
"addon.mod_h5pactivity.review_my_attempts": "h5pactivity", "addon.mod_h5pactivity.review_user_attempts": "h5pactivity",
"addon.mod_h5pactivity.score": "h5pactivity", "addon.mod_h5pactivity.score": "h5pactivity",
"addon.mod_h5pactivity.score_out_of": "h5pactivity", "addon.mod_h5pactivity.score_out_of": "h5pactivity",
"addon.mod_h5pactivity.startdate": "h5pactivity", "addon.mod_h5pactivity.startdate": "h5pactivity",
@ -855,8 +891,6 @@
"addon.mod_quiz.requirepasswordmessage": "quizaccess_password", "addon.mod_quiz.requirepasswordmessage": "quizaccess_password",
"addon.mod_quiz.returnattempt": "quiz", "addon.mod_quiz.returnattempt": "quiz",
"addon.mod_quiz.review": "quiz", "addon.mod_quiz.review": "quiz",
"addon.mod_quiz.reviewofattempt": "quiz",
"addon.mod_quiz.reviewofpreview": "quiz",
"addon.mod_quiz.showall": "quiz", "addon.mod_quiz.showall": "quiz",
"addon.mod_quiz.showeachpage": "quiz", "addon.mod_quiz.showeachpage": "quiz",
"addon.mod_quiz.startattempt": "quiz", "addon.mod_quiz.startattempt": "quiz",
@ -883,6 +917,8 @@
"addon.mod_resource.modifieddate": "resource", "addon.mod_resource.modifieddate": "resource",
"addon.mod_resource.modulenameplural": "resource", "addon.mod_resource.modulenameplural": "resource",
"addon.mod_resource.openthefile": "local_moodlemobileapp", "addon.mod_resource.openthefile": "local_moodlemobileapp",
"addon.mod_resource.resourcestatusoutdated": "local_moodlemobileapp",
"addon.mod_resource.resourcestatusoutdatedconfirm": "local_moodlemobileapp",
"addon.mod_resource.uploadeddate": "resource", "addon.mod_resource.uploadeddate": "resource",
"addon.mod_scorm.asset": "scorm", "addon.mod_scorm.asset": "scorm",
"addon.mod_scorm.assetlaunched": "scorm", "addon.mod_scorm.assetlaunched": "scorm",
@ -1069,12 +1105,30 @@
"addon.privatefiles.sitefiles": "moodle", "addon.privatefiles.sitefiles": "moodle",
"addon.qtype_essay.maxwordlimitboundary": "qtype_essay", "addon.qtype_essay.maxwordlimitboundary": "qtype_essay",
"addon.qtype_essay.minwordlimitboundary": "qtype_essay", "addon.qtype_essay.minwordlimitboundary": "qtype_essay",
"addon.storagemanager.deletecourse": "local_moodlemobileapp", "addon.report_insights.actionsaved": "report_insights",
"addon.report_insights.fixedack": "analytics",
"addon.report_insights.incorrectlyflagged": "analytics",
"addon.report_insights.notapplicable": "analytics",
"addon.report_insights.notuseful": "analytics",
"addon.report_insights.useful": "analytics",
"addon.storagemanager.alldata": "tool_wp",
"addon.storagemanager.confirmdeleteallsitedata": "local_moodlemobileapp",
"addon.storagemanager.confirmdeletecourses": "local_moodlemobileapp",
"addon.storagemanager.confirmdeletedatafrom": "local_moodlemobileapp",
"addon.storagemanager.coursedownloads": "local_moodlemobileapp",
"addon.storagemanager.courseinfo": "local_moodlemobileapp",
"addon.storagemanager.deleteall": "moodle",
"addon.storagemanager.deleteallsitedata": "local_moodlemobileapp",
"addon.storagemanager.deleteallsitedatainfo": "local_moodlemobileapp",
"addon.storagemanager.deletecourses": "local_moodlemobileapp", "addon.storagemanager.deletecourses": "local_moodlemobileapp",
"addon.storagemanager.deletedata": "local_moodlemobileapp",
"addon.storagemanager.deletedatafrom": "local_moodlemobileapp", "addon.storagemanager.deletedatafrom": "local_moodlemobileapp",
"addon.storagemanager.info": "local_moodlemobileapp", "addon.storagemanager.downloadedcourses": "local_moodlemobileapp",
"addon.storagemanager.managestorage": "local_moodlemobileapp", "addon.storagemanager.downloads": "local_moodlemobileapp",
"addon.storagemanager.storageused": "local_moodlemobileapp", "addon.storagemanager.errordeletedownloadeddata": "local_moodlemobileapp",
"addon.storagemanager.managedownloads": "local_moodlemobileapp",
"addon.storagemanager.totaldownloads": "local_moodlemobileapp",
"addon.storagemanager.totalspaceusage": "local_moodlemobileapp",
"assets.countries.AD": "countries", "assets.countries.AD": "countries",
"assets.countries.AE": "countries", "assets.countries.AE": "countries",
"assets.countries.AF": "countries", "assets.countries.AF": "countries",
@ -1324,9 +1378,23 @@
"assets.countries.ZA": "countries", "assets.countries.ZA": "countries",
"assets.countries.ZM": "countries", "assets.countries.ZM": "countries",
"assets.countries.ZW": "countries", "assets.countries.ZW": "countries",
"assets.mimetypes.application/dash_xml": "mimetypes",
"assets.mimetypes.application/epub_zip": "mimetypes", "assets.mimetypes.application/epub_zip": "mimetypes",
"assets.mimetypes.application/json": "mimetypes",
"assets.mimetypes.application/msword": "mimetypes", "assets.mimetypes.application/msword": "mimetypes",
"assets.mimetypes.application/pdf": "mimetypes", "assets.mimetypes.application/pdf": "mimetypes",
"assets.mimetypes.application/vnd.google-apps.audio": "local_moodlemobileapp",
"assets.mimetypes.application/vnd.google-apps.document": "local_moodlemobileapp",
"assets.mimetypes.application/vnd.google-apps.drawing": "local_moodlemobileapp",
"assets.mimetypes.application/vnd.google-apps.file": "local_moodlemobileapp",
"assets.mimetypes.application/vnd.google-apps.folder": "local_moodlemobileapp",
"assets.mimetypes.application/vnd.google-apps.form": "local_moodlemobileapp",
"assets.mimetypes.application/vnd.google-apps.fusiontable": "local_moodlemobileapp",
"assets.mimetypes.application/vnd.google-apps.presentation": "local_moodlemobileapp",
"assets.mimetypes.application/vnd.google-apps.script": "local_moodlemobileapp",
"assets.mimetypes.application/vnd.google-apps.site": "local_moodlemobileapp",
"assets.mimetypes.application/vnd.google-apps.spreadsheet": "local_moodlemobileapp",
"assets.mimetypes.application/vnd.google-apps.video": "local_moodlemobileapp",
"assets.mimetypes.application/vnd.moodle.backup": "mimetypes", "assets.mimetypes.application/vnd.moodle.backup": "mimetypes",
"assets.mimetypes.application/vnd.ms-excel": "mimetypes", "assets.mimetypes.application/vnd.ms-excel": "mimetypes",
"assets.mimetypes.application/vnd.ms-excel.sheet.macroEnabled.12": "mimetypes", "assets.mimetypes.application/vnd.ms-excel.sheet.macroEnabled.12": "mimetypes",
@ -1345,6 +1413,7 @@
"assets.mimetypes.application/x-iwork-numbers-sffnumbers": "mimetypes", "assets.mimetypes.application/x-iwork-numbers-sffnumbers": "mimetypes",
"assets.mimetypes.application/x-iwork-pages-sffpages": "mimetypes", "assets.mimetypes.application/x-iwork-pages-sffpages": "mimetypes",
"assets.mimetypes.application/x-javascript": "mimetypes", "assets.mimetypes.application/x-javascript": "mimetypes",
"assets.mimetypes.application/x-mpegURL": "mimetypes",
"assets.mimetypes.application/x-mspublisher": "mimetypes", "assets.mimetypes.application/x-mspublisher": "mimetypes",
"assets.mimetypes.application/x-shockwave-flash": "mimetypes", "assets.mimetypes.application/x-shockwave-flash": "mimetypes",
"assets.mimetypes.application/xhtml_xml": "mimetypes", "assets.mimetypes.application/xhtml_xml": "mimetypes",
@ -1359,6 +1428,8 @@
"assets.mimetypes.group:html_track": "mimetypes", "assets.mimetypes.group:html_track": "mimetypes",
"assets.mimetypes.group:html_video": "mimetypes", "assets.mimetypes.group:html_video": "mimetypes",
"assets.mimetypes.group:image": "mimetypes", "assets.mimetypes.group:image": "mimetypes",
"assets.mimetypes.group:media_source": "mimetypes",
"assets.mimetypes.group:optimised_image": "mimetypes",
"assets.mimetypes.group:presentation": "mimetypes", "assets.mimetypes.group:presentation": "mimetypes",
"assets.mimetypes.group:sourcecode": "mimetypes", "assets.mimetypes.group:sourcecode": "mimetypes",
"assets.mimetypes.group:spreadsheet": "mimetypes", "assets.mimetypes.group:spreadsheet": "mimetypes",
@ -1380,6 +1451,7 @@
"core.add": "moodle", "core.add": "moodle",
"core.agelocationverification": "moodle", "core.agelocationverification": "moodle",
"core.ago": "message", "core.ago": "message",
"core.ajaxendpointnotfound": "local_moodlemobileapp",
"core.all": "moodle", "core.all": "moodle",
"core.allgroups": "moodle", "core.allgroups": "moodle",
"core.allparticipants": "moodle", "core.allparticipants": "moodle",
@ -1388,12 +1460,18 @@
"core.areyousure": "moodle", "core.areyousure": "moodle",
"core.back": "moodle", "core.back": "moodle",
"core.block.blocks": "moodle", "core.block.blocks": "moodle",
"core.block.noblocks": "error",
"core.block.opendrawerblocks": "moodle",
"core.block.tour_navigation_dashboard_content": "tool_usertours",
"core.block.tour_navigation_dashboard_title": "tool_usertours",
"core.browser": "local_moodlemobileapp", "core.browser": "local_moodlemobileapp",
"core.calculating": "local_moodlemobileapp",
"core.cancel": "moodle", "core.cancel": "moodle",
"core.cannotconnect": "local_moodlemobileapp", "core.cannotconnect": "local_moodlemobileapp",
"core.cannotconnecttrouble": "local_moodlemobileapp", "core.cannotconnecttrouble": "local_moodlemobileapp",
"core.cannotconnectverify": "local_moodlemobileapp", "core.cannotconnectverify": "local_moodlemobileapp",
"core.cannotdownloadfiles": "local_moodlemobileapp", "core.cannotdownloadfiles": "local_moodlemobileapp",
"core.cannotlogoutpageblocks": "local_moodlemobileapp",
"core.cannotopeninapp": "local_moodlemobileapp", "core.cannotopeninapp": "local_moodlemobileapp",
"core.cannotopeninappdownload": "local_moodlemobileapp", "core.cannotopeninappdownload": "local_moodlemobileapp",
"core.captureaudio": "local_moodlemobileapp", "core.captureaudio": "local_moodlemobileapp",
@ -1401,6 +1479,7 @@
"core.captureimage": "local_moodlemobileapp", "core.captureimage": "local_moodlemobileapp",
"core.capturevideo": "local_moodlemobileapp", "core.capturevideo": "local_moodlemobileapp",
"core.category": "moodle", "core.category": "moodle",
"core.certificaterror": "local_moodlemobileapp",
"core.choose": "moodle", "core.choose": "moodle",
"core.choosedots": "moodle", "core.choosedots": "moodle",
"core.clearsearch": "local_moodlemobileapp", "core.clearsearch": "local_moodlemobileapp",
@ -1433,8 +1512,6 @@
"core.completion-alt-manual-y-override": "completion", "core.completion-alt-manual-y-override": "completion",
"core.confirmcanceledit": "local_moodlemobileapp", "core.confirmcanceledit": "local_moodlemobileapp",
"core.confirmdeletefile": "repository", "core.confirmdeletefile": "repository",
"core.confirmgotabroot": "local_moodlemobileapp",
"core.confirmgotabrootdefault": "local_moodlemobileapp",
"core.confirmleaveunknownchanges": "local_moodlemobileapp", "core.confirmleaveunknownchanges": "local_moodlemobileapp",
"core.confirmloss": "local_moodlemobileapp", "core.confirmloss": "local_moodlemobileapp",
"core.confirmopeninbrowser": "local_moodlemobileapp", "core.confirmopeninbrowser": "local_moodlemobileapp",
@ -1453,10 +1530,8 @@
"core.course": "moodle", "core.course": "moodle",
"core.course.activitydisabled": "local_moodlemobileapp", "core.course.activitydisabled": "local_moodlemobileapp",
"core.course.activitynotyetviewableremoteaddon": "local_moodlemobileapp", "core.course.activitynotyetviewableremoteaddon": "local_moodlemobileapp",
"core.course.activitynotyetviewablesiteupgradeneeded": "local_moodlemobileapp",
"core.course.allsections": "local_moodlemobileapp", "core.course.allsections": "local_moodlemobileapp",
"core.course.aria:sectionprogress": "local_moodlemobileapp", "core.course.aria:sectionprogress": "local_moodlemobileapp",
"core.course.askadmintosupport": "local_moodlemobileapp",
"core.course.availablespace": "local_moodlemobileapp", "core.course.availablespace": "local_moodlemobileapp",
"core.course.cannotdeletewhiledownloading": "local_moodlemobileapp", "core.course.cannotdeletewhiledownloading": "local_moodlemobileapp",
"core.course.completion_automatic:done": "course", "core.course.completion_automatic:done": "course",
@ -1471,34 +1546,47 @@
"core.course.completion_setby:manual:done": "course", "core.course.completion_setby:manual:done": "course",
"core.course.completion_setby:manual:markdone": "course", "core.course.completion_setby:manual:markdone": "course",
"core.course.completionrequirements": "course", "core.course.completionrequirements": "course",
"core.course.confirmdeletemodulefiles": "local_moodlemobileapp",
"core.course.confirmdeletestoreddata": "local_moodlemobileapp",
"core.course.confirmdownload": "local_moodlemobileapp", "core.course.confirmdownload": "local_moodlemobileapp",
"core.course.confirmdownloadunknownsize": "local_moodlemobileapp", "core.course.confirmdownloadunknownsize": "local_moodlemobileapp",
"core.course.confirmdownloadzerosize": "local_moodlemobileapp", "core.course.confirmdownloadzerosize": "local_moodlemobileapp",
"core.course.confirmlimiteddownload": "local_moodlemobileapp", "core.course.confirmlimiteddownload": "local_moodlemobileapp",
"core.course.confirmpartialdownloadsize": "local_moodlemobileapp", "core.course.confirmpartialdownloadsize": "local_moodlemobileapp",
"core.course.contents": "local_moodlemobileapp",
"core.course.couldnotloadsectioncontent": "local_moodlemobileapp", "core.course.couldnotloadsectioncontent": "local_moodlemobileapp",
"core.course.couldnotloadsections": "local_moodlemobileapp", "core.course.couldnotloadsections": "local_moodlemobileapp",
"core.course.courseindex": "courseformat",
"core.course.coursesummary": "moodle", "core.course.coursesummary": "moodle",
"core.course.done": "completion",
"core.course.downloadcourse": "tool_mobile", "core.course.downloadcourse": "tool_mobile",
"core.course.downloadcoursesprogressdescription": "local_moodlemobileapp", "core.course.downloadcoursesprogressdescription": "local_moodlemobileapp",
"core.course.downloadsectionprogressdescription": "local_moodlemobileapp", "core.course.downloadsectionprogressdescription": "local_moodlemobileapp",
"core.course.enddate": "moodle",
"core.course.errordownloadingcourse": "local_moodlemobileapp", "core.course.errordownloadingcourse": "local_moodlemobileapp",
"core.course.errordownloadingsection": "local_moodlemobileapp", "core.course.errordownloadingsection": "local_moodlemobileapp",
"core.course.errorgetmodule": "local_moodlemobileapp", "core.course.errorgetmodule": "local_moodlemobileapp",
"core.course.failed": "completion",
"core.course.hiddenfromstudents": "moodle", "core.course.hiddenfromstudents": "moodle",
"core.course.hiddenoncoursepage": "moodle", "core.course.hiddenoncoursepage": "moodle",
"core.course.highlighted": "moodle",
"core.course.insufficientavailablequota": "local_moodlemobileapp", "core.course.insufficientavailablequota": "local_moodlemobileapp",
"core.course.insufficientavailablespace": "local_moodlemobileapp", "core.course.insufficientavailablespace": "local_moodlemobileapp",
"core.course.lastaccessedactivity": "local_moodlemobileapp",
"core.course.manualcompletionnotsynced": "local_moodlemobileapp", "core.course.manualcompletionnotsynced": "local_moodlemobileapp",
"core.course.modulenotfound": "local_moodlemobileapp",
"core.course.nextactivity": "local_moodlemobileapp",
"core.course.nextactivitynotfound": "local_moodlemobileapp",
"core.course.nocontentavailable": "local_moodlemobileapp", "core.course.nocontentavailable": "local_moodlemobileapp",
"core.course.overriddennotice": "grades", "core.course.overriddennotice": "grades",
"core.course.previousactivity": "local_moodlemobileapp",
"core.course.previousactivitynotfound": "local_moodlemobileapp",
"core.course.refreshcourse": "local_moodlemobileapp", "core.course.refreshcourse": "local_moodlemobileapp",
"core.course.section": "moodle", "core.course.section": "moodle",
"core.course.sections": "moodle", "core.course.startdate": "moodle",
"core.course.thisweek": "format_weeks/currentsection",
"core.course.todo": "completion",
"core.course.tour_navigation_course_index_student_content": "tool_usertours",
"core.course.tour_navigation_course_index_student_title": "tool_usertours",
"core.course.useactivityonbrowser": "local_moodlemobileapp", "core.course.useactivityonbrowser": "local_moodlemobileapp",
"core.course.viewcourse": "block_timeline",
"core.course.warningmanualcompletionmodified": "local_moodlemobileapp", "core.course.warningmanualcompletionmodified": "local_moodlemobileapp",
"core.course.warningofflinemanualcompletiondeleted": "local_moodlemobileapp", "core.course.warningofflinemanualcompletiondeleted": "local_moodlemobileapp",
"core.coursedetails": "moodle", "core.coursedetails": "moodle",
@ -1510,8 +1598,10 @@
"core.courses.aria:courseprogress": "block_myoverview", "core.courses.aria:courseprogress": "block_myoverview",
"core.courses.aria:favourite": "course", "core.courses.aria:favourite": "course",
"core.courses.availablecourses": "moodle", "core.courses.availablecourses": "moodle",
"core.courses.browserenrolinstructions": "local_moodlemobileapp",
"core.courses.cannotretrievemorecategories": "local_moodlemobileapp", "core.courses.cannotretrievemorecategories": "local_moodlemobileapp",
"core.courses.categories": "moodle", "core.courses.categories": "moodle",
"core.courses.completeenrolmentbrowser": "local_moodlemobileapp",
"core.courses.confirmselfenrol": "local_moodlemobileapp", "core.courses.confirmselfenrol": "local_moodlemobileapp",
"core.courses.courses": "moodle", "core.courses.courses": "moodle",
"core.courses.downloadcourses": "local_moodlemobileapp", "core.courses.downloadcourses": "local_moodlemobileapp",
@ -1533,22 +1623,25 @@
"core.courses.nosearchresults": "wiki", "core.courses.nosearchresults": "wiki",
"core.courses.notenroled": "completion", "core.courses.notenroled": "completion",
"core.courses.notenrollable": "local_moodlemobileapp", "core.courses.notenrollable": "local_moodlemobileapp",
"core.courses.otherenrolments": "local_moodlemobileapp",
"core.courses.password": "local_moodlemobileapp", "core.courses.password": "local_moodlemobileapp",
"core.courses.paymentrequired": "moodle", "core.courses.paymentrequired": "moodle",
"core.courses.paypalaccepted": "enrol_paypal", "core.courses.refreshcourses": "local_moodlemobileapp",
"core.courses.reload": "moodle", "core.courses.reload": "moodle",
"core.courses.removefromfavourites": "block_myoverview", "core.courses.removefromfavourites": "block_myoverview",
"core.courses.search": "moodle", "core.courses.search": "moodle",
"core.courses.searchcourses": "moodle", "core.courses.searchcourses": "moodle",
"core.courses.searchcoursesadvice": "local_moodlemobileapp", "core.courses.searchcoursesadvice": "local_moodlemobileapp",
"core.courses.selfenrolment": "local_moodlemobileapp", "core.courses.selfenrolment": "local_moodlemobileapp",
"core.courses.sendpaymentbutton": "enrol_paypal",
"core.courses.show": "block_myoverview", "core.courses.show": "block_myoverview",
"core.courses.showonlyenrolled": "local_moodlemobileapp",
"core.courses.therearecourses": "moodle", "core.courses.therearecourses": "moodle",
"core.courses.totalcoursesearchresults": "local_moodlemobileapp", "core.courses.totalcoursesearchresults": "local_moodlemobileapp",
"core.currentdevice": "local_moodlemobileapp", "core.currentdevice": "local_moodlemobileapp",
"core.custom": "form",
"core.datastoredoffline": "local_moodlemobileapp", "core.datastoredoffline": "local_moodlemobileapp",
"core.date": "moodle", "core.date": "moodle",
"core.datecreated": "repository",
"core.day": "moodle", "core.day": "moodle",
"core.days": "moodle", "core.days": "moodle",
"core.decsep": "langconfig", "core.decsep": "langconfig",
@ -1567,10 +1660,12 @@
"core.dftimedate": "local_moodlemobileapp", "core.dftimedate": "local_moodlemobileapp",
"core.digitalminor": "moodle", "core.digitalminor": "moodle",
"core.digitalminor_desc": "moodle", "core.digitalminor_desc": "moodle",
"core.disablefullscreen": "h5p",
"core.discard": "local_moodlemobileapp", "core.discard": "local_moodlemobileapp",
"core.dismiss": "local_moodlemobileapp", "core.dismiss": "local_moodlemobileapp",
"core.displayoptions": "atto_media", "core.displayoptions": "atto_media",
"core.done": "survey", "core.done": "survey",
"core.dontshowagain": "local_moodlemobileapp",
"core.download": "moodle", "core.download": "moodle",
"core.downloaded": "local_moodlemobileapp", "core.downloaded": "local_moodlemobileapp",
"core.downloadfile": "moodle", "core.downloadfile": "moodle",
@ -1592,6 +1687,7 @@
"core.editor.underline": "atto_underline/pluginname", "core.editor.underline": "atto_underline/pluginname",
"core.editor.unorderedlist": "atto_unorderedlist/pluginname", "core.editor.unorderedlist": "atto_unorderedlist/pluginname",
"core.emptysplit": "local_moodlemobileapp", "core.emptysplit": "local_moodlemobileapp",
"core.endonesteptour": "tool_usertours",
"core.error": "moodle", "core.error": "moodle",
"core.errorchangecompletion": "local_moodlemobileapp", "core.errorchangecompletion": "local_moodlemobileapp",
"core.errordeletefile": "local_moodlemobileapp", "core.errordeletefile": "local_moodlemobileapp",
@ -1650,6 +1746,8 @@
"core.forcepasswordchangenotice": "moodle", "core.forcepasswordchangenotice": "moodle",
"core.fulllistofcourses": "moodle", "core.fulllistofcourses": "moodle",
"core.fullnameandsitename": "local_moodlemobileapp", "core.fullnameandsitename": "local_moodlemobileapp",
"core.fullscreen": "h5p",
"core.goto": "local_moodlemobileapp",
"core.grades.aggregatemean": "grades", "core.grades.aggregatemean": "grades",
"core.grades.aggregatesum": "grades", "core.grades.aggregatesum": "grades",
"core.grades.average": "grades", "core.grades.average": "grades",
@ -1657,8 +1755,10 @@
"core.grades.calculatedgrade": "grades", "core.grades.calculatedgrade": "grades",
"core.grades.category": "grades", "core.grades.category": "grades",
"core.grades.contributiontocoursetotal": "grades", "core.grades.contributiontocoursetotal": "grades",
"core.grades.fail": "grades",
"core.grades.feedback": "grades", "core.grades.feedback": "grades",
"core.grades.grade": "grades", "core.grades.grade": "grades",
"core.grades.gradebook": "grades",
"core.grades.gradeitem": "grades", "core.grades.gradeitem": "grades",
"core.grades.gradepass": "grades", "core.grades.gradepass": "grades",
"core.grades.grades": "grades", "core.grades.grades": "grades",
@ -1667,6 +1767,7 @@
"core.grades.nogradesreturned": "grades", "core.grades.nogradesreturned": "grades",
"core.grades.nooutcome": "grades", "core.grades.nooutcome": "grades",
"core.grades.outcome": "grades", "core.grades.outcome": "grades",
"core.grades.pass": "grades",
"core.grades.percentage": "grades", "core.grades.percentage": "grades",
"core.grades.range": "grades", "core.grades.range": "grades",
"core.grades.rank": "grades", "core.grades.rank": "grades",
@ -1734,6 +1835,7 @@
"core.h5p.licensee": "h5p", "core.h5p.licensee": "h5p",
"core.h5p.licenseextras": "h5p", "core.h5p.licenseextras": "h5p",
"core.h5p.licenseversion": "h5p", "core.h5p.licenseversion": "h5p",
"core.h5p.missingdependency": "h5p",
"core.h5p.nocopyright": "h5p", "core.h5p.nocopyright": "h5p",
"core.h5p.offlineDialogBody": "h5p", "core.h5p.offlineDialogBody": "h5p",
"core.h5p.offlineDialogHeader": "h5p", "core.h5p.offlineDialogHeader": "h5p",
@ -1789,6 +1891,8 @@
"core.loading": "moodle", "core.loading": "moodle",
"core.loadmore": "local_moodlemobileapp", "core.loadmore": "local_moodlemobileapp",
"core.location": "moodle", "core.location": "moodle",
"core.login.accounts": "admin",
"core.login.add": "moodle",
"core.login.auth_email": "auth_email/pluginname", "core.login.auth_email": "auth_email/pluginname",
"core.login.authenticating": "local_moodlemobileapp", "core.login.authenticating": "local_moodlemobileapp",
"core.login.cancel": "moodle", "core.login.cancel": "moodle",
@ -1846,7 +1950,6 @@
"core.login.invalidurl": "scorm", "core.login.invalidurl": "scorm",
"core.login.invalidvaluemax": "local_moodlemobileapp", "core.login.invalidvaluemax": "local_moodlemobileapp",
"core.login.invalidvaluemin": "local_moodlemobileapp", "core.login.invalidvaluemin": "local_moodlemobileapp",
"core.login.localmobileunexpectedresponse": "local_moodlemobileapp",
"core.login.loggedoutssodescription": "local_moodlemobileapp", "core.login.loggedoutssodescription": "local_moodlemobileapp",
"core.login.login": "moodle", "core.login.login": "moodle",
"core.login.loginbutton": "local_moodlemobileapp", "core.login.loginbutton": "local_moodlemobileapp",
@ -1875,6 +1978,7 @@
"core.login.passwordforgotteninstructions2": "moodle", "core.login.passwordforgotteninstructions2": "moodle",
"core.login.passwordrequired": "local_moodlemobileapp", "core.login.passwordrequired": "local_moodlemobileapp",
"core.login.policyaccept": "moodle", "core.login.policyaccept": "moodle",
"core.login.policyacceptmandatory": "local_moodlemobileapp",
"core.login.policyagree": "moodle", "core.login.policyagree": "moodle",
"core.login.policyagreement": "moodle", "core.login.policyagreement": "moodle",
"core.login.policyagreementclick": "moodle", "core.login.policyagreementclick": "moodle",
@ -1884,8 +1988,8 @@
"core.login.recaptchaexpired": "local_moodlemobileapp", "core.login.recaptchaexpired": "local_moodlemobileapp",
"core.login.recaptchaincorrect": "local_moodlemobileapp", "core.login.recaptchaincorrect": "local_moodlemobileapp",
"core.login.reconnect": "local_moodlemobileapp", "core.login.reconnect": "local_moodlemobileapp",
"core.login.reconnectdescription": "local_moodlemobileapp",
"core.login.reconnectssodescription": "local_moodlemobileapp", "core.login.reconnectssodescription": "local_moodlemobileapp",
"core.login.removeaccount": "local_moodlemobileapp",
"core.login.resendemail": "moodle", "core.login.resendemail": "moodle",
"core.login.searchby": "local_moodlemobileapp", "core.login.searchby": "local_moodlemobileapp",
"core.login.security_question": "auth", "core.login.security_question": "auth",
@ -1898,12 +2002,14 @@
"core.login.sitebadgedescription": "local_moodlemobileapp", "core.login.sitebadgedescription": "local_moodlemobileapp",
"core.login.sitehasredirect": "local_moodlemobileapp", "core.login.sitehasredirect": "local_moodlemobileapp",
"core.login.siteinmaintenance": "local_moodlemobileapp", "core.login.siteinmaintenance": "local_moodlemobileapp",
"core.login.sitenotallowed": "local_moodlemobileapp",
"core.login.sitepolicynotagreederror": "local_moodlemobileapp", "core.login.sitepolicynotagreederror": "local_moodlemobileapp",
"core.login.siteurl": "local_moodlemobileapp", "core.login.siteurl": "local_moodlemobileapp",
"core.login.siteurlrequired": "local_moodlemobileapp", "core.login.siteurlrequired": "local_moodlemobileapp",
"core.login.startsignup": "moodle", "core.login.startsignup": "moodle",
"core.login.stillcantconnect": "local_moodlemobileapp", "core.login.stillcantconnect": "local_moodlemobileapp",
"core.login.supplyinfo": "moodle", "core.login.supplyinfo": "moodle",
"core.login.toggleremove": "local_moodlemobileapp",
"core.login.username": "moodle", "core.login.username": "moodle",
"core.login.usernameoremail": "moodle", "core.login.usernameoremail": "moodle",
"core.login.usernamerequired": "local_moodlemobileapp", "core.login.usernamerequired": "local_moodlemobileapp",
@ -1913,23 +2019,23 @@
"core.login.youcanstillconnectwithcredentials": "local_moodlemobileapp", "core.login.youcanstillconnectwithcredentials": "local_moodlemobileapp",
"core.login.yourenteredsite": "local_moodlemobileapp", "core.login.yourenteredsite": "local_moodlemobileapp",
"core.lostconnection": "local_moodlemobileapp", "core.lostconnection": "local_moodlemobileapp",
"core.mainmenu.changesite": "local_moodlemobileapp",
"core.mainmenu.help": "moodle",
"core.mainmenu.home": "moodle", "core.mainmenu.home": "moodle",
"core.mainmenu.logout": "moodle", "core.mainmenu.logout": "moodle",
"core.mainmenu.website": "local_moodlemobileapp", "core.mainmenu.switchaccount": "local_moodlemobileapp",
"core.mainmenu.usermenutourdescription": "local_moodlemobileapp",
"core.mainmenu.usermenutourtitle": "local_moodlemobileapp",
"core.maxfilesize": "moodle", "core.maxfilesize": "moodle",
"core.maxsizeandattachments": "moodle", "core.maxsizeandattachments": "moodle",
"core.min": "moodle", "core.min": "moodle",
"core.mins": "moodle", "core.mins": "moodle",
"core.minute": "moodle",
"core.minutes": "moodle",
"core.misc": "admin", "core.misc": "admin",
"core.mod_assign": "assign/pluginname", "core.mod_assign": "assign/pluginname",
"core.mod_assignment": "assignment/pluginname",
"core.mod_book": "book/pluginname", "core.mod_book": "book/pluginname",
"core.mod_chat": "chat/pluginname", "core.mod_chat": "chat/pluginname",
"core.mod_choice": "choice/pluginname", "core.mod_choice": "choice/pluginname",
"core.mod_data": "data/pluginname", "core.mod_data": "data/pluginname",
"core.mod_database": "data/pluginname",
"core.mod_external-tool": "lti/pluginname", "core.mod_external-tool": "lti/pluginname",
"core.mod_feedback": "feedback/pluginname", "core.mod_feedback": "feedback/pluginname",
"core.mod_file": "moodle/file", "core.mod_file": "moodle/file",
@ -1937,7 +2043,6 @@
"core.mod_forum": "forum/pluginname", "core.mod_forum": "forum/pluginname",
"core.mod_glossary": "glossary/pluginname", "core.mod_glossary": "glossary/pluginname",
"core.mod_h5pactivity": "h5pactivity/pluginname", "core.mod_h5pactivity": "h5pactivity/pluginname",
"core.mod_ims": "imscp/pluginname",
"core.mod_imscp": "imscp/pluginname", "core.mod_imscp": "imscp/pluginname",
"core.mod_label": "label/pluginname", "core.mod_label": "label/pluginname",
"core.mod_lesson": "lesson/pluginname", "core.mod_lesson": "lesson/pluginname",
@ -1951,7 +2056,7 @@
"core.mod_wiki": "wiki/pluginname", "core.mod_wiki": "wiki/pluginname",
"core.mod_workshop": "workshop/pluginname", "core.mod_workshop": "workshop/pluginname",
"core.moduleintro": "moodle", "core.moduleintro": "moodle",
"core.more": "moodle", "core.more": "moodle/moremenu",
"core.mygroups": "group", "core.mygroups": "group",
"core.name": "moodle", "core.name": "moodle",
"core.needhelp": "local_moodlemobileapp", "core.needhelp": "local_moodlemobileapp",
@ -1991,7 +2096,6 @@
"core.othergroups": "group", "core.othergroups": "group",
"core.pagea": "moodle", "core.pagea": "moodle",
"core.parentlanguage": "langconfig", "core.parentlanguage": "langconfig",
"core.paymentinstant": "moodle",
"core.percentagenumber": "local_moodlemobileapp", "core.percentagenumber": "local_moodlemobileapp",
"core.phone": "moodle", "core.phone": "moodle",
"core.pictureof": "moodle", "core.pictureof": "moodle",
@ -2039,6 +2143,7 @@
"core.resources": "moodle", "core.resources": "moodle",
"core.restore": "moodle", "core.restore": "moodle",
"core.restricted": "moodle", "core.restricted": "moodle",
"core.resume": "local_moodlemobileapp",
"core.retry": "local_moodlemobileapp", "core.retry": "local_moodlemobileapp",
"core.save": "moodle", "core.save": "moodle",
"core.savechanges": "assign", "core.savechanges": "assign",
@ -2058,11 +2163,14 @@
"core.sending": "chat", "core.sending": "chat",
"core.serverconnection": "error", "core.serverconnection": "error",
"core.settings.about": "local_moodlemobileapp", "core.settings.about": "local_moodlemobileapp",
"core.settings.accessstatement": "access",
"core.settings.appsettings": "local_moodlemobileapp", "core.settings.appsettings": "local_moodlemobileapp",
"core.settings.appversion": "local_moodlemobileapp", "core.settings.appversion": "local_moodlemobileapp",
"core.settings.cannotsyncloggedout": "local_moodlemobileapp", "core.settings.cannotsyncloggedout": "local_moodlemobileapp",
"core.settings.cannotsyncoffline": "local_moodlemobileapp", "core.settings.cannotsyncoffline": "local_moodlemobileapp",
"core.settings.cannotsyncwithoutwifi": "local_moodlemobileapp", "core.settings.cannotsyncwithoutwifi": "local_moodlemobileapp",
"core.settings.changelanguage": "local_moodlemobileapp",
"core.settings.changelanguagealert": "local_moodlemobileapp",
"core.settings.colorscheme": "local_moodlemobileapp", "core.settings.colorscheme": "local_moodlemobileapp",
"core.settings.colorscheme-dark": "local_moodlemobileapp", "core.settings.colorscheme-dark": "local_moodlemobileapp",
"core.settings.colorscheme-light": "local_moodlemobileapp", "core.settings.colorscheme-light": "local_moodlemobileapp",
@ -2078,12 +2186,12 @@
"core.settings.currentlanguage": "moodle", "core.settings.currentlanguage": "moodle",
"core.settings.debugdisplay": "admin", "core.settings.debugdisplay": "admin",
"core.settings.debugdisplaydescription": "local_moodlemobileapp", "core.settings.debugdisplaydescription": "local_moodlemobileapp",
"core.settings.deletesitefiles": "local_moodlemobileapp", "core.settings.developeroptions": "local_moodlemobileapp",
"core.settings.deletesitefilestitle": "local_moodlemobileapp",
"core.settings.deviceinfo": "local_moodlemobileapp", "core.settings.deviceinfo": "local_moodlemobileapp",
"core.settings.deviceos": "local_moodlemobileapp", "core.settings.deviceos": "local_moodlemobileapp",
"core.settings.disableall": "message", "core.settings.disableall": "message",
"core.settings.disabled": "lesson", "core.settings.disabled": "lesson",
"core.settings.disallowed": "message",
"core.settings.displayformat": "local_moodlemobileapp", "core.settings.displayformat": "local_moodlemobileapp",
"core.settings.enabledownloadsection": "local_moodlemobileapp", "core.settings.enabledownloadsection": "local_moodlemobileapp",
"core.settings.enablefirebaseanalytics": "local_moodlemobileapp", "core.settings.enablefirebaseanalytics": "local_moodlemobileapp",
@ -2092,12 +2200,12 @@
"core.settings.enablerichtexteditordescription": "local_moodlemobileapp", "core.settings.enablerichtexteditordescription": "local_moodlemobileapp",
"core.settings.enablesyncwifi": "local_moodlemobileapp", "core.settings.enablesyncwifi": "local_moodlemobileapp",
"core.settings.entriesincache": "local_moodlemobileapp", "core.settings.entriesincache": "local_moodlemobileapp",
"core.settings.errordeletesitefiles": "local_moodlemobileapp",
"core.settings.errorsyncsite": "local_moodlemobileapp", "core.settings.errorsyncsite": "local_moodlemobileapp",
"core.settings.estimatedfreespace": "local_moodlemobileapp", "core.settings.estimatedfreespace": "local_moodlemobileapp",
"core.settings.filesystemroot": "local_moodlemobileapp", "core.settings.filesystemroot": "local_moodlemobileapp",
"core.settings.fontsize": "local_moodlemobileapp", "core.settings.fontsize": "local_moodlemobileapp",
"core.settings.fontsizecharacter": "block_accessibility/char", "core.settings.fontsizecharacter": "block_accessibility/char",
"core.settings.forced": "message",
"core.settings.forcedsetting": "local_moodlemobileapp", "core.settings.forcedsetting": "local_moodlemobileapp",
"core.settings.general": "moodle", "core.settings.general": "moodle",
"core.settings.helpusimprove": "local_moodlemobileapp", "core.settings.helpusimprove": "local_moodlemobileapp",
@ -2107,7 +2215,6 @@
"core.settings.license": "moodle", "core.settings.license": "moodle",
"core.settings.localnotifavailable": "local_moodlemobileapp", "core.settings.localnotifavailable": "local_moodlemobileapp",
"core.settings.locationhref": "local_moodlemobileapp", "core.settings.locationhref": "local_moodlemobileapp",
"core.settings.locked": "admin",
"core.settings.loggedin": "message", "core.settings.loggedin": "message",
"core.settings.loggedoff": "message", "core.settings.loggedoff": "message",
"core.settings.navigatorlanguage": "local_moodlemobileapp", "core.settings.navigatorlanguage": "local_moodlemobileapp",
@ -2125,13 +2232,13 @@
"core.settings.siteinfo": "local_moodlemobileapp", "core.settings.siteinfo": "local_moodlemobileapp",
"core.settings.sites": "moodle", "core.settings.sites": "moodle",
"core.settings.spaceusage": "local_moodlemobileapp", "core.settings.spaceusage": "local_moodlemobileapp",
"core.settings.spaceusagehelp": "local_moodlemobileapp",
"core.settings.synchronization": "local_moodlemobileapp", "core.settings.synchronization": "local_moodlemobileapp",
"core.settings.synchronizenow": "local_moodlemobileapp", "core.settings.synchronizenow": "local_moodlemobileapp",
"core.settings.synchronizenowhelp": "local_moodlemobileapp", "core.settings.synchronizenowhelp": "local_moodlemobileapp",
"core.settings.syncsettings": "local_moodlemobileapp", "core.settings.syncsettings": "local_moodlemobileapp",
"core.settings.total": "moodle", "core.settings.total": "moodle",
"core.settings.wificonnection": "local_moodlemobileapp", "core.settings.wificonnection": "local_moodlemobileapp",
"core.settings.youradev": "local_moodlemobileapp",
"core.sharedfiles.chooseaccountstorefile": "local_moodlemobileapp", "core.sharedfiles.chooseaccountstorefile": "local_moodlemobileapp",
"core.sharedfiles.chooseactionrepeatedfile": "local_moodlemobileapp", "core.sharedfiles.chooseactionrepeatedfile": "local_moodlemobileapp",
"core.sharedfiles.errorreceivefilenosites": "local_moodlemobileapp", "core.sharedfiles.errorreceivefilenosites": "local_moodlemobileapp",
@ -2149,6 +2256,7 @@
"core.sitehome.sitehome": "moodle", "core.sitehome.sitehome": "moodle",
"core.sitehome.sitenews": "moodle", "core.sitehome.sitenews": "moodle",
"core.sitemaintenance": "admin", "core.sitemaintenance": "admin",
"core.size": "moodle",
"core.sizeb": "moodle", "core.sizeb": "moodle",
"core.sizegb": "moodle", "core.sizegb": "moodle",
"core.sizekb": "moodle", "core.sizekb": "moodle",
@ -2158,7 +2266,7 @@
"core.sorry": "local_moodlemobileapp", "core.sorry": "local_moodlemobileapp",
"core.sort": "moodle", "core.sort": "moodle",
"core.sortby": "moodle", "core.sortby": "moodle",
"core.start": "grouptool", "core.start": "local_moodlemobileapp",
"core.storingfiles": "local_moodlemobileapp", "core.storingfiles": "local_moodlemobileapp",
"core.strftimedate": "langconfig", "core.strftimedate": "langconfig",
"core.strftimedatefullshort": "langconfig", "core.strftimedatefullshort": "langconfig",
@ -2177,6 +2285,8 @@
"core.strftimetime24": "langconfig", "core.strftimetime24": "langconfig",
"core.submit": "moodle", "core.submit": "moodle",
"core.success": "moodle", "core.success": "moodle",
"core.summary": "moodle",
"core.swipenavigationtourdescription": "local_moodlemobileapp",
"core.tablet": "local_moodlemobileapp", "core.tablet": "local_moodlemobileapp",
"core.tag.defautltagcoll": "tag", "core.tag.defautltagcoll": "tag",
"core.tag.errorareanotsupported": "local_moodlemobileapp", "core.tag.errorareanotsupported": "local_moodlemobileapp",
@ -2203,6 +2313,7 @@
"core.toggledelete": "local_moodlemobileapp", "core.toggledelete": "local_moodlemobileapp",
"core.tryagain": "local_moodlemobileapp", "core.tryagain": "local_moodlemobileapp",
"core.twoparagraphs": "local_moodlemobileapp", "core.twoparagraphs": "local_moodlemobileapp",
"core.type": "repository",
"core.uhoh": "local_moodlemobileapp", "core.uhoh": "local_moodlemobileapp",
"core.unexpectederror": "local_moodlemobileapp", "core.unexpectederror": "local_moodlemobileapp",
"core.unicodenotsupported": "local_moodlemobileapp", "core.unicodenotsupported": "local_moodlemobileapp",
@ -2234,26 +2345,32 @@
"core.user.participants": "moodle", "core.user.participants": "moodle",
"core.user.phone1": "moodle", "core.user.phone1": "moodle",
"core.user.phone2": "moodle", "core.user.phone2": "moodle",
"core.user.profile": "moodle",
"core.user.roles": "moodle", "core.user.roles": "moodle",
"core.user.sendemail": "local_moodlemobileapp", "core.user.sendemail": "local_moodlemobileapp",
"core.user.student": "moodle/defaultcoursestudent", "core.user.student": "moodle/defaultcoursestudent",
"core.user.teacher": "moodle/noneditingteacher", "core.user.teacher": "moodle/noneditingteacher",
"core.user.useraccount": "moodle",
"core.user.userwithid": "local_moodlemobileapp", "core.user.userwithid": "local_moodlemobileapp",
"core.user.webpage": "moodle", "core.user.webpage": "moodle",
"core.userdeleted": "moodle", "core.userdeleted": "moodle",
"core.userdetails": "moodle", "core.userdetails": "moodle",
"core.usernologin": "local_moodlemobileapp",
"core.usernotfullysetup": "error", "core.usernotfullysetup": "error",
"core.users": "moodle", "core.users": "moodle",
"core.usersuspended": "tool_reportbuilder",
"core.view": "moodle", "core.view": "moodle",
"core.viewcode": "local_moodlemobileapp", "core.viewcode": "local_moodlemobileapp",
"core.vieweditor": "local_moodlemobileapp", "core.vieweditor": "local_moodlemobileapp",
"core.viewembeddedcontent": "local_moodlemobileapp", "core.viewembeddedcontent": "local_moodlemobileapp",
"core.viewprofile": "moodle", "core.viewprofile": "moodle",
"core.warningofflinedatadeleted": "local_moodlemobileapp", "core.warningofflinedatadeleted": "local_moodlemobileapp",
"core.warnopeninbrowser": "local_moodlemobileapp",
"core.week": "moodle",
"core.weeks": "moodle",
"core.whatisyourage": "moodle", "core.whatisyourage": "moodle",
"core.wheredoyoulive": "moodle", "core.wheredoyoulive": "moodle",
"core.whoissiteadmin": "local_moodlemobileapp", "core.whoissiteadmin": "local_moodlemobileapp",
"core.whoops": "local_moodlemobileapp",
"core.whyisthishappening": "local_moodlemobileapp", "core.whyisthishappening": "local_moodlemobileapp",
"core.whyisthisrequired": "moodle", "core.whyisthisrequired": "moodle",
"core.wsfunctionnotavailable": "local_moodlemobileapp", "core.wsfunctionnotavailable": "local_moodlemobileapp",
@ -2261,5 +2378,7 @@
"core.years": "moodle", "core.years": "moodle",
"core.yes": "moodle", "core.yes": "moodle",
"core.youreoffline": "local_moodlemobileapp", "core.youreoffline": "local_moodlemobileapp",
"core.youreonline": "local_moodlemobileapp" "core.youreonline": "local_moodlemobileapp",
"core.zoomin": "local_moodlemobileapp",
"core.zoomout": "local_moodlemobileapp"
} }

View File

@ -0,0 +1,87 @@
#!/usr/bin/env node
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const { readdirSync, readFileSync } = require('fs');
if (process.argv.length < 3) {
console.error('Missing measure timings storage path argument');
process.exit(1);
}
const performanceMeasuresStoragePath = process.argv[2].trimRight('/') + '/';
const files = readdirSync(performanceMeasuresStoragePath);
const performanceMeasures = {};
if (files.length === 0) {
console.log('No logs found!');
process.exit(0);
}
// Aggregate data
for (const file of files) {
const performanceMeasure = JSON.parse(readFileSync(performanceMeasuresStoragePath + file));
performanceMeasures[performanceMeasure.name] = performanceMeasures[performanceMeasure.name] ?? {
duration: [],
scripting: [],
styling: [],
blocking: [],
longTasks: [],
database: [],
networking: [],
};
performanceMeasures[performanceMeasure.name].duration.push(performanceMeasure.duration);
performanceMeasures[performanceMeasure.name].scripting.push(performanceMeasure.scripting);
performanceMeasures[performanceMeasure.name].styling.push(performanceMeasure.styling);
performanceMeasures[performanceMeasure.name].blocking.push(performanceMeasure.blocking);
performanceMeasures[performanceMeasure.name].longTasks.push(performanceMeasure.longTasks);
performanceMeasures[performanceMeasure.name].database.push(performanceMeasure.database);
performanceMeasures[performanceMeasure.name].networking.push(performanceMeasure.networking);
}
// Calculate averages
for (const [name, { duration, scripting, styling, blocking, longTasks, database, networking }] of Object.entries(performanceMeasures)) {
const totalRuns = duration.length;
const averageDuration = Math.round(duration.reduce((total, duration) => total + duration) / totalRuns);
const averageScripting = Math.round(scripting.reduce((total, scripting) => total + scripting) / totalRuns);
const averageStyling = Math.round(styling.reduce((total, styling) => total + styling) / totalRuns);
const averageBlocking = Math.round(blocking.reduce((total, blocking) => total + blocking) / totalRuns);
const averageLongTasks = Math.round(longTasks.reduce((total, longTasks) => total + longTasks) / totalRuns);
const averageDatabase = Math.round(database.reduce((total, database) => total + database) / totalRuns);
const averageNetworking = Math.round(networking.reduce((total, networking) => total + networking) / totalRuns);
performanceMeasures[name] = {
'Total duration': `${averageDuration}ms`,
'Scripting': `${averageScripting}ms`,
'Styling': `${averageStyling}ms`,
'Blocking': `${averageBlocking}ms`,
'# Network requests': averageNetworking,
'# DB Queries': averageDatabase,
'# Long Tasks': averageLongTasks,
'# runs': totalRuns,
};
}
// Sort tests
const tests = Object.keys(performanceMeasures).sort();
const sortedPerformanceMeasures = {};
for (const test of tests) {
sortedPerformanceMeasures[test] = performanceMeasures[test];
}
// Display data
console.table(sortedPerformanceMeasures);

32
scripts/serve.sh 100755
View File

@ -0,0 +1,32 @@
#!/bin/bash
# This script is necessary because @ionic/cli is passing one argument to the ionic:serve hook
# that is unsupported by angular cli: https://github.com/ionic-team/ionic-cli/issues/4743
#
# Once the issue is fixed, this script can be replaced adding the following npm script:
#
# "ionic:serve": "gulp watch & NODE_OPTIONS=--max-old-space-size=4096 ng serve"
#
# Run gulp watch.
echo "> gulp watch &"
gulp watch &
# Remove unknown arguments and prepare angular target.
args=("$@")
angulartarget="serve"
total=${#args[@]}
for ((i=0; i<total; ++i)); do
case ${args[i]} in
--project=*)
unset args[i];
;;
--platform=*)
angulartarget="ionic-cordova-serve";
;;
esac
done
# Serve app.
echo "> NODE_OPTIONS=--max-old-space-size=4096 ng run app:$angulartarget ${args[@]}"
NODE_OPTIONS=--max-old-space-size=4096 ng run "app:$angulartarget" ${args[@]}

View File

@ -0,0 +1,32 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace local_moodleappbehat\privacy;
use core_privacy\local\metadata\null_provider;
defined('MOODLE_INTERNAL') || die();
class provider implements null_provider {
/**
* @inheritdoc
*/
public static function get_reason() : string {
return 'privacy_metadata';
}
}

View File

@ -1,3 +1,4 @@
<?php <?php
$string['pluginname'] = 'Moodle App Behat (auto-generated)'; $string['pluginname'] = 'Moodle App Behat (auto-generated)';
$string['privacy_metadata'] = 'This plugin should only be used in development environments, and it does not store any user data.';

View File

@ -10,7 +10,7 @@ source "lang_functions.sh"
forceLang=$1 forceLang=$1
print_title 'Getting local mobile langs' print_title 'Getting local mobile langs'
git clone --depth 1 https://github.com/moodlehq/moodle-local_moodlemobileapp.git ../../moodle-local_moodlemobileapp git clone --branch integration --depth 1 https://github.com/moodlehq/moodle-local_moodlemobileapp.git ../../moodle-local_moodlemobileapp
if [ -z $forceLang ]; then if [ -z $forceLang ]; then
get_languages get_languages

View File

@ -30,6 +30,7 @@ import { AddonPrivateFilesModule } from './privatefiles/privatefiles.module';
import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module'; import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module';
import { AddonQtypeModule } from './qtype/qtype.module'; import { AddonQtypeModule } from './qtype/qtype.module';
import { AddonRemoteThemesModule } from './remotethemes/remotethemes.module'; import { AddonRemoteThemesModule } from './remotethemes/remotethemes.module';
import { AddonReportModule } from './report/report.module';
import { AddonStorageManagerModule } from './storagemanager/storagemanager.module'; import { AddonStorageManagerModule } from './storagemanager/storagemanager.module';
import { AddonUserProfileFieldModule } from './userprofilefield/userprofilefield.module'; import { AddonUserProfileFieldModule } from './userprofilefield/userprofilefield.module';
@ -51,6 +52,7 @@ import { AddonUserProfileFieldModule } from './userprofilefield/userprofilefield
AddonQbehaviourModule, AddonQbehaviourModule,
AddonQtypeModule, AddonQtypeModule,
AddonRemoteThemesModule, AddonRemoteThemesModule,
AddonReportModule,
AddonStorageManagerModule, AddonStorageManagerModule,
AddonUserProfileFieldModule, AddonUserProfileFieldModule,
], ],

View File

@ -44,8 +44,7 @@ const mainMenuRoutes: Routes = [
{ {
provide: APP_INITIALIZER, provide: APP_INITIALIZER,
multi: true, multi: true,
deps: [], useValue: () => {
useFactory: () => () => {
CoreContentLinksDelegate.registerHandler(AddonBadgesMyBadgesLinkHandler.instance); CoreContentLinksDelegate.registerHandler(AddonBadgesMyBadgesLinkHandler.instance);
CoreContentLinksDelegate.registerHandler(AddonBadgesBadgeLinkHandler.instance); CoreContentLinksDelegate.registerHandler(AddonBadgesBadgeLinkHandler.instance);
CoreUserDelegate.registerHandler(AddonBadgesUserHandler.instance); CoreUserDelegate.registerHandler(AddonBadgesUserHandler.instance);

View File

@ -0,0 +1,60 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Params } from '@angular/router';
import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source';
import { AddonBadges, AddonBadgesUserBadge } from '../services/badges';
/**
* Provides a collection of user badges.
*/
export class AddonBadgesUserBadgesSource extends CoreRoutedItemsManagerSource<AddonBadgesUserBadge> {
readonly COURSE_ID: number;
readonly USER_ID: number;
constructor(courseId: number, userId: number) {
super();
this.COURSE_ID = courseId;
this.USER_ID = userId;
}
/**
* @inheritdoc
*/
getItemPath(badge: AddonBadgesUserBadge): string {
return badge.uniquehash;
}
/**
* @inheritdoc
*/
getItemQueryParams(): Params {
return {
courseId: this.COURSE_ID,
userId: this.USER_ID,
};
}
/**
* @inheritdoc
*/
protected async loadPageItems(): Promise<{ items: AddonBadgesUserBadge[] }> {
const badges = await AddonBadges.getUserBadges(this.COURSE_ID, this.USER_ID);
return { items: badges };
}
}

View File

@ -20,10 +20,10 @@
"issuerurl": "Issuer URL", "issuerurl": "Issuer URL",
"language": "Language", "language": "Language",
"noalignment": "This badge does not have any external skills or standards specified.", "noalignment": "This badge does not have any external skills or standards specified.",
"nobadges": "There are no badges available.", "nobadges": "There are currently no badges available for users to earn.",
"norelated": "This badge does not have any related badges.", "norelated": "This badge does not have any related badges.",
"recipientdetails": "Recipient details", "recipientdetails": "Recipient details",
"relatedbages": "Related badges", "relatedbages": "Related badges",
"version": "Version", "version": "Version",
"warnexpired": "(This badge has expired!)" "warnexpired": "(This badge has expired!)"
} }

View File

@ -3,11 +3,13 @@
<ion-buttons slot="start"> <ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button> <ion-back-button [text]="'core.back' | translate"></ion-back-button>
</ion-buttons> </ion-buttons>
<h1 *ngIf="badge">{{ badge.name }}</h1> <ion-title>
<h1 *ngIf="!badge">{{ 'addon.badges.badges' | translate }}</h1> <h1 *ngIf="badge">{{ badge.name }}</h1>
<h1 *ngIf="!badge">{{ 'addon.badges.badges' | translate }}</h1>
</ion-title>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content> <ion-content [core-swipe-navigation]="badges" class="limited-width">
<ion-refresher slot="fixed" [disabled]="!badgeLoaded" (ionRefresh)="refreshBadges($event.target)"> <ion-refresher slot="fixed" [disabled]="!badgeLoaded" (ionRefresh)="refreshBadges($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher> </ion-refresher>
@ -53,7 +55,9 @@
<ion-item class="ion-text-wrap" *ngIf="badge.issuercontact"> <ion-item class="ion-text-wrap" *ngIf="badge.issuercontact">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.contact' | translate}}</h2> <h2>{{ 'addon.badges.contact' | translate}}</h2>
<p><a href="mailto:{{badge.issuercontact}}" core-link auto-login="no"> {{ badge.issuercontact }} </a></p> <p><a href="mailto:{{badge.issuercontact}}" core-link auto-login="no" [showBrowserWarning]="false">
{{ badge.issuercontact }}
</a></p>
</ion-label> </ion-label>
</ion-item> </ion-item>
</ion-item-group> </ion-item-group>
@ -97,7 +101,9 @@
<ion-item class="ion-text-wrap" *ngIf="badge.imageauthoremail"> <ion-item class="ion-text-wrap" *ngIf="badge.imageauthoremail">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.imageauthoremail' | translate}}</h2> <h2>{{ 'addon.badges.imageauthoremail' | translate}}</h2>
<p><a href="mailto:{{badge.imageauthoremail}}" core-link auto-login="no"> {{ badge.imageauthoremail }} </a></p> <p><a href="mailto:{{badge.imageauthoremail}}" core-link auto-login="no" [showBrowserWarning]="false">
{{ badge.imageauthoremail }}
</a></p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.imageauthorurl"> <ion-item class="ion-text-wrap" *ngIf="badge.imageauthorurl">
@ -153,7 +159,9 @@
<!-- Endorsement --> <!-- Endorsement -->
<ion-item-group *ngIf="badge.endorsement"> <ion-item-group *ngIf="badge.endorsement">
<ion-item-divider> <ion-item-divider>
<ion-label><h2>{{ 'addon.badges.bendorsement' | translate}}</h2></ion-label> <ion-label>
<h2>{{ 'addon.badges.bendorsement' | translate}}</h2>
</ion-label>
</ion-item-divider> </ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issuername"> <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issuername">
<ion-label> <ion-label>
@ -165,7 +173,7 @@
<ion-label> <ion-label>
<h2>{{ 'addon.badges.issueremail' | translate}}</h2> <h2>{{ 'addon.badges.issueremail' | translate}}</h2>
<p> <p>
<a href="mailto:{{badge.endorsement.issueremail}}" core-link auto-login="no"> <a href="mailto:{{badge.endorsement.issueremail}}" core-link auto-login="no" [showBrowserWarning]="false">
{{ badge.endorsement.issueremail }} {{ badge.endorsement.issueremail }}
</a> </a>
</p> </p>
@ -200,27 +208,39 @@
<!-- Related badges --> <!-- Related badges -->
<ion-item-group *ngIf="badge.relatedbadges"> <ion-item-group *ngIf="badge.relatedbadges">
<ion-item-divider> <ion-item-divider>
<ion-label><h2>{{ 'addon.badges.relatedbages' | translate}}</h2></ion-label> <ion-label>
<h2>{{ 'addon.badges.relatedbages' | translate}}</h2>
</ion-label>
</ion-item-divider> </ion-item-divider>
<ion-item class="ion-text-wrap" *ngFor="let relatedBadge of badge.relatedbadges"> <ion-item class="ion-text-wrap" *ngFor="let relatedBadge of badge.relatedbadges">
<ion-label><h2>{{ relatedBadge.name }}</h2></ion-label> <ion-label>
<h2>{{ relatedBadge.name }}</h2>
</ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.relatedbadges.length == 0"> <ion-item class="ion-text-wrap" *ngIf="badge.relatedbadges.length == 0">
<ion-label><h2>{{ 'addon.badges.norelated' | translate}}</h2></ion-label> <ion-label>
<h2>{{ 'addon.badges.norelated' | translate}}</h2>
</ion-label>
</ion-item> </ion-item>
</ion-item-group> </ion-item-group>
<!-- Competencies alignment --> <!-- Competencies alignment -->
<ion-item-group *ngIf="badge.alignment"> <ion-item-group *ngIf="badge.alignment">
<ion-item-divider> <ion-item-divider>
<ion-label><h2>{{ 'addon.badges.alignment' | translate}}</h2></ion-label> <ion-label>
<h2>{{ 'addon.badges.alignment' | translate}}</h2>
</ion-label>
</ion-item-divider> </ion-item-divider>
<ion-item class="ion-text-wrap" *ngFor="let alignment of badge.alignment" [href]="alignment.targeturl" core-link <ion-item class="ion-text-wrap" *ngFor="let alignment of badge.alignment" [href]="alignment.targeturl" core-link
auto-login="no"> auto-login="no">
<ion-label><h2>{{ alignment.targetname }}</h2></ion-label> <ion-label>
<h2>{{ alignment.targetname }}</h2>
</ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.alignment.length == 0"> <ion-item class="ion-text-wrap" *ngIf="badge.alignment.length == 0">
<ion-label><h2>{{ 'addon.badges.noalignment' | translate}}</h2></ion-label> <ion-label>
<h2>{{ 'addon.badges.noalignment' | translate}}</h2>
</ion-label>
</ion-item> </ion-item>
</ion-item-group> </ion-item-group>
</ng-container> </ng-container>

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { Component, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { IonRefresher } from '@ionic/angular'; import { IonRefresher } from '@ionic/angular';
import { CoreTimeUtils } from '@services/utils/time'; import { CoreTimeUtils } from '@services/utils/time';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
@ -23,6 +23,9 @@ import { CoreUtils } from '@services/utils/utils';
import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses'; import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager';
import { AddonBadgesUserBadgesSource } from '@addons/badges/classes/user-badges-source';
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker';
/** /**
* Page that displays the list of calendar events. * Page that displays the list of calendar events.
@ -31,7 +34,7 @@ import { ActivatedRoute } from '@angular/router';
selector: 'page-addon-badges-issued-badge', selector: 'page-addon-badges-issued-badge',
templateUrl: 'issued-badge.html', templateUrl: 'issued-badge.html',
}) })
export class AddonBadgesIssuedBadgePage implements OnInit { export class AddonBadgesIssuedBadgePage implements OnInit, OnDestroy {
protected badgeHash = ''; protected badgeHash = '';
protected userId!: number; protected userId!: number;
@ -40,24 +43,39 @@ export class AddonBadgesIssuedBadgePage implements OnInit {
user?: CoreUserProfile; user?: CoreUserProfile;
course?: CoreEnrolledCourseData; course?: CoreEnrolledCourseData;
badge?: AddonBadgesUserBadge; badge?: AddonBadgesUserBadge;
badges: CoreSwipeNavigationItemsManager;
badgeLoaded = false; badgeLoaded = false;
currentTime = 0; currentTime = 0;
constructor( constructor(protected route: ActivatedRoute) {
protected route: ActivatedRoute, this.courseId = CoreNavigator.getRouteNumberParam('courseId') || this.courseId; // Use 0 for site badges.
) { } this.userId = CoreNavigator.getRouteNumberParam('userId') || CoreSites.getRequiredCurrentSite().getUserId();
this.badgeHash = CoreNavigator.getRouteParam('badgeHash') || '';
const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(
AddonBadgesUserBadgesSource,
[this.courseId, this.userId],
);
this.badges = new CoreSwipeNavigationItemsManager(source);
}
/** /**
* View loaded. * View loaded.
*/ */
ngOnInit(): void { ngOnInit(): void {
this.courseId = CoreNavigator.getRouteNumberParam('courseId') || this.courseId; // Use 0 for site badges.
this.userId = CoreNavigator.getRouteNumberParam('userId') || CoreSites.getCurrentSite()!.getUserId();
this.badgeHash = CoreNavigator.getRouteParam('badgeHash') || '';
this.fetchIssuedBadge().finally(() => { this.fetchIssuedBadge().finally(() => {
this.badgeLoaded = true; this.badgeLoaded = true;
}); });
this.badges.start();
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.badges.destroy();
} }
/** /**

View File

@ -3,7 +3,9 @@
<ion-buttons slot="start"> <ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button> <ion-back-button [text]="'core.back' | translate"></ion-back-button>
</ion-buttons> </ion-buttons>
<h1>{{ 'addon.badges.badges' | translate }}</h1> <ion-title>
<h1>{{ 'addon.badges.badges' | translate }}</h1>
</ion-title>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content> <ion-content>
@ -12,8 +14,7 @@
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher> </ion-refresher>
<core-loading [hideUntil]="badges.loaded"> <core-loading [hideUntil]="badges.loaded">
<core-empty-box *ngIf="badges.empty" icon="fas-trophy" <core-empty-box *ngIf="badges.empty" icon="fas-trophy" [message]="'addon.badges.nobadges' | translate">
[message]="'addon.badges.nobadges' | translate">
</core-empty-box> </core-empty-box>
<ion-list *ngIf="!badges.empty" class="ion-no-margin"> <ion-list *ngIf="!badges.empty" class="ion-no-margin">

View File

@ -19,10 +19,11 @@ import { CoreTimeUtils } from '@services/utils/time';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CorePageItemsListManager } from '@classes/page-items-list-manager';
import { Params } from '@angular/router';
import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreListItemsManager } from '@classes/items-management/list-items-manager';
import { AddonBadgesUserBadgesSource } from '@addons/badges/classes/user-badges-source';
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker';
/** /**
* Page that displays the list of calendar events. * Page that displays the list of calendar events.
@ -34,15 +35,23 @@ import { CoreNavigator } from '@services/navigator';
export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy { export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy {
currentTime = 0; currentTime = 0;
badges: AddonBadgesUserBadgesManager; badges: CoreListItemsManager<AddonBadgesUserBadge, AddonBadgesUserBadgesSource>;
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
constructor() { constructor() {
const courseId = CoreNavigator.getRouteNumberParam('courseId') ?? 0; // Use 0 for site badges. let courseId = CoreNavigator.getRouteNumberParam('courseId') ?? 0; // Use 0 for site badges.
const userId = CoreNavigator.getRouteNumberParam('userId') ?? CoreSites.getCurrentSiteUserId(); const userId = CoreNavigator.getRouteNumberParam('userId') ?? CoreSites.getCurrentSiteUserId();
this.badges = new AddonBadgesUserBadgesManager(AddonBadgesUserBadgesPage, courseId, userId); if (courseId === CoreSites.getCurrentSiteHomeId()) {
// Use courseId 0 for site home, otherwise the site doesn't return site badges.
courseId = 0;
}
this.badges = new CoreListItemsManager(
CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(AddonBadgesUserBadgesSource, [courseId, userId]),
AddonBadgesUserBadgesPage,
);
} }
/** /**
@ -67,8 +76,13 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy {
* @param refresher Refresher. * @param refresher Refresher.
*/ */
async refreshBadges(refresher?: IonRefresher): Promise<void> { async refreshBadges(refresher?: IonRefresher): Promise<void> {
await CoreUtils.ignoreErrors(AddonBadges.invalidateUserBadges(this.badges.courseId, this.badges.userId)); await CoreUtils.ignoreErrors(
await CoreUtils.ignoreErrors(this.fetchBadges()); AddonBadges.invalidateUserBadges(
this.badges.getSource().COURSE_ID,
this.badges.getSource().USER_ID,
),
);
await CoreUtils.ignoreErrors(this.badges.reload());
refresher?.complete(); refresher?.complete();
} }
@ -80,55 +94,12 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy {
this.currentTime = CoreTimeUtils.timestamp(); this.currentTime = CoreTimeUtils.timestamp();
try { try {
await this.fetchBadges(); await this.badges.reload();
} catch (message) { } catch (message) {
CoreDomUtils.showErrorModalDefault(message, 'Error loading badges'); CoreDomUtils.showErrorModalDefault(message, 'Error loading badges');
this.badges.setItems([]); this.badges.reset();
} }
} }
/**
* Update the list of badges.
*/
private async fetchBadges(): Promise<void> {
const badges = await AddonBadges.getUserBadges(this.badges.courseId, this.badges.userId);
this.badges.setItems(badges);
}
}
/**
* Helper class to manage badges.
*/
class AddonBadgesUserBadgesManager extends CorePageItemsListManager<AddonBadgesUserBadge> {
courseId: number;
userId: number;
constructor(pageComponent: unknown, courseId: number, userId: number) {
super(pageComponent);
this.courseId = courseId;
this.userId = userId;
}
/**
* @inheritdoc
*/
protected getItemPath(badge: AddonBadgesUserBadge): string {
return badge.uniquehash;
}
/**
* @inheritdoc
*/
protected getItemQueryParams(): Params {
return {
courseId: this.courseId,
userId: this.userId,
};
}
} }

View File

@ -39,7 +39,7 @@ export class AddonBadgesProvider {
async isPluginEnabled(siteId?: string): Promise<boolean> { async isPluginEnabled(siteId?: string): Promise<boolean> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
return site.canUseAdvancedFeature('enablebadges') && site.wsAvailable('core_course_get_user_navigation_options'); return site.canUseAdvancedFeature('enablebadges');
} }
/** /**
@ -83,7 +83,7 @@ export class AddonBadgesProvider {
badge.alignment = badge.alignment || badge.competencies; badge.alignment = badge.alignment || badge.competencies;
// Check that the alignment is valid, they were broken in 3.7. // Check that the alignment is valid, they were broken in 3.7.
if (badge.alignment && badge.alignment[0] && typeof badge.alignment[0].targetname == 'undefined') { if (badge.alignment && badge.alignment[0] && badge.alignment[0].targetname === undefined) {
// If any badge lacks targetname it means they are affected by the Moodle bug, don't display them. // If any badge lacks targetname it means they are affected by the Moodle bug, don't display them.
delete badge.alignment; delete badge.alignment;
} }
@ -194,7 +194,7 @@ export type AddonBadgesUserBadge = {
targetframework?: string; // Target framework. targetframework?: string; // Target framework.
targetcode?: string; // Target code. targetcode?: string; // Target code.
}[]; }[];
competencies?: { // @deprecated from 3.7. @since 3.6. In 3.7 it was renamed to alignment. competencies?: { // @deprecatedonmoodle from 3.7. @since 3.6. In 3.7 it was renamed to alignment.
id?: number; // Alignment id. id?: number; // Alignment id.
badgeid?: number; // Badge id. badgeid?: number; // Badge id.
targetname?: string; // Target name. targetname?: string; // Target name.

View File

@ -14,8 +14,14 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses'; import { CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses';
import { CoreUserDelegateService, CoreUserProfileHandler, CoreUserProfileHandlerData } from '@features/user/services/user-delegate'; import {
CoreUserDelegateContext,
CoreUserDelegateService,
CoreUserProfileHandler,
CoreUserProfileHandlerData,
} from '@features/user/services/user-delegate';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
import { AddonBadges } from '../badges'; import { AddonBadges } from '../badges';
@ -25,52 +31,58 @@ import { AddonBadges } from '../badges';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AddonBadgesUserHandlerService implements CoreUserProfileHandler { export class AddonBadgesUserHandlerService implements CoreUserProfileHandler {
name = 'AddonBadges'; name = 'AddonBadges:fakename'; // This name doesn't match any disabled feature, they'll be checked in isEnabledForContext.
priority = 50; priority = 300;
type = CoreUserDelegateService.TYPE_NEW_PAGE; type = CoreUserDelegateService.TYPE_NEW_PAGE;
/** /**
* Check if handler is enabled. * @inheritdoc
*
* @return Always enabled.
*/ */
isEnabled(): Promise<boolean> { isEnabled(): Promise<boolean> {
return AddonBadges.isPluginEnabled(); return AddonBadges.isPluginEnabled();
} }
/** /**
* Check if handler is enabled for this user in this context. * @inheritdoc
*
* @param courseId Course ID.
* @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions.
* @return True if enabled, false otherwise.
*/ */
async isEnabledForCourse( async isEnabledForContext(
context: CoreUserDelegateContext,
courseId: number, courseId: number,
navOptions?: CoreCourseUserAdminOrNavOptionIndexed, navOptions?: CoreCourseUserAdminOrNavOptionIndexed,
): Promise<boolean> { ): Promise<boolean> {
if (navOptions && typeof navOptions.badges != 'undefined') { // Check if feature is disabled.
const currentSite = CoreSites.getCurrentSite();
if (!currentSite) {
return false;
}
if (context === CoreUserDelegateContext.USER_MENU) {
if (currentSite.isFeatureDisabled('CoreUserDelegate_AddonBadges:account')) {
return false;
}
} else if (currentSite.isFeatureDisabled('CoreUserDelegate_AddonBadges')) {
return false;
}
if (navOptions && navOptions.badges !== undefined) {
return navOptions.badges; return navOptions.badges;
} }
// If we reach here, it means we are opening the user site profile.
return true; return true;
} }
/** /**
* Returns the data needed to render the handler. * @inheritdoc
*
* @return Data needed to render the handler.
*/ */
getDisplayData(): CoreUserProfileHandlerData { getDisplayData(): CoreUserProfileHandlerData {
return { return {
icon: 'fas-trophy', icon: 'fas-trophy',
title: 'addon.badges.badges', title: 'addon.badges.badges',
action: (event, user, courseId): void => { action: (event, user, context, contextId): void => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
CoreNavigator.navigateToSitePath('/badges', { CoreNavigator.navigateToSitePath('/badges', {
params: { courseId, userId: user.id }, params: { courseId: contextId, userId: user.id },
}); });
}, },
}; };

View File

@ -0,0 +1,13 @@
:host {
--mod-icon-filter: brightness(0);
core-mod-icon {
background: transparent;
margin: 0;
--filter: var(--mod-icon-filter);
}
}
:host-context(body.dark) {
--mod-icon-filter: brightness(0) invert(1);
}

View File

@ -21,6 +21,7 @@ import { ContextLevel, CoreConstants } from '@/core/constants';
import { Translate } from '@singletons'; import { Translate } from '@singletons';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreCourseHelper } from '@features/course/services/course-helper';
/** /**
* Component to render an "activity modules" block. * Component to render an "activity modules" block.
@ -28,6 +29,7 @@ import { CoreNavigator } from '@services/navigator';
@Component({ @Component({
selector: 'addon-block-activitymodules', selector: 'addon-block-activitymodules',
templateUrl: 'addon-block-activitymodules.html', templateUrl: 'addon-block-activitymodules.html',
styleUrls: ['activitymodules.scss'],
}) })
export class AddonBlockActivityModulesComponent extends CoreBlockBaseComponent implements OnInit { export class AddonBlockActivityModulesComponent extends CoreBlockBaseComponent implements OnInit {
@ -66,14 +68,14 @@ export class AddonBlockActivityModulesComponent extends CoreBlockBaseComponent i
} }
section.modules.forEach((mod) => { section.modules.forEach((mod) => {
if (mod.uservisible === false || !CoreCourse.moduleHasView(mod) || if (!CoreCourseHelper.canUserViewModule(mod, section) || !CoreCourse.moduleHasView(mod) ||
typeof modFullNames[mod.modname] != 'undefined') { modFullNames[mod.modname] !== undefined) {
// Ignore this module. // Ignore this module.
return; return;
} }
// Get the archetype of the module type. // Get the archetype of the module type.
if (typeof archetypes[mod.modname] == 'undefined') { if (archetypes[mod.modname] === undefined) {
archetypes[mod.modname] = CoreCourseModuleDelegate.supportsFeature<number>( archetypes[mod.modname] = CoreCourseModuleDelegate.supportsFeature<number>(
mod.modname, mod.modname,
CoreConstants.FEATURE_MOD_ARCHETYPE, CoreConstants.FEATURE_MOD_ARCHETYPE,
@ -96,16 +98,13 @@ export class AddonBlockActivityModulesComponent extends CoreBlockBaseComponent i
// Sort the modnames alphabetically. // Sort the modnames alphabetically.
modFullNames = CoreUtils.sortValues(modFullNames); modFullNames = CoreUtils.sortValues(modFullNames);
for (const modName in modFullNames) { for (const modName in modFullNames) {
let icon: string; const iconModName = modName === 'resources' ? 'page' : modName;
if (modName === 'resources') { const icon = await CoreCourseModuleDelegate.getModuleIconSrc(iconModName, modIcons[iconModName]);
icon = CoreCourse.getModuleIconSrc('page', modIcons['page']);
} else {
icon = CoreCourseModuleDelegate.getModuleIconSrc(modName, modIcons[modName]) || '';
}
this.entries.push({ this.entries.push({
icon: icon, icon,
iconModName,
name: modFullNames[modName], name: modFullNames[modName],
modName, modName,
}); });
@ -145,4 +144,5 @@ type AddonBlockActivityModuleEntry = {
icon: string; icon: string;
name: string; name: string;
modName: string; modName: string;
iconModName: string;
}; };

View File

@ -1,12 +1,12 @@
<ion-item-divider sticky="true"> <ion-item-divider sticky="true">
<ion-label> <ion-label>
<h2>{{ 'addon.block_activitymodules.pluginname' | translate }}</h2> <h2>{{ 'addon.block_activitymodules.pluginname' | translate }}</h2>
</ion-label> </ion-label>
</ion-item-divider> </ion-item-divider>
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="margin"> <core-loading [hideUntil]="loaded">
<ion-item class="ion-text-wrap item-media" *ngFor="let entry of entries" detail="true" button <ion-item class="ion-text-wrap" *ngFor="let entry of entries" detail="true" button (click)="gotoCoureListModType(entry)">
(click)="gotoCoureListModType(entry)"> <core-mod-icon slot="start" [modicon]="entry.icon" [modname]="entry.iconModName" [showAlt]="false">
<img slot="start" [src]="entry.icon" alt="" role="presentation" class="core-module-icon"> </core-mod-icon>
<ion-label>{{ entry.name }}</ion-label> <ion-label>{{ entry.name }}</ion-label>
</ion-item> </ion-item>
</core-loading> </core-loading>

View File

@ -21,7 +21,7 @@
text-align: start; text-align: start;
padding-top: .75rem; padding-top: .75rem;
padding-bottom: .75rem; padding-bottom: .75rem;
color: var(--gray-darker); color: var(--medium);
font-weight: bold; font-weight: bold;
font-size: 18px; font-size: 18px;
} }

View File

@ -1,21 +1,42 @@
:host .core-block-content ::ng-deep { :host {
ul.badges { --badge-size: 100px;
list-style: none; --badge-container-size: 150px;
margin-left: 0;
margin-right: 0;
-webkit-padding-start: 0;
li { .core-block-content ::ng-deep {
position: relative;
display: inline-block;
padding-top: 1em;
text-align: center;
vertical-align: top;
width: 150px;
.badge-name { ul.badges {
display: block; list-style: none;
padding: 5px; margin: 0;
li {
position: relative;
display: inline-block;
text-align: center;
margin-top: 1em;
vertical-align: top;
width: var(--badge-container-size);
.badge-name {
display: block;
padding: 5px;
}
.badge-image {
width: var(--badge-size);
}
.expireimage {
content: 'expired';
background-image: url('/assets/img/expired.svg');
background-repeat: no-repeat;
background-size: var(--badge-size) var(--badge-size);
width: var(--badge-size);
height: var(--badge-size);
left: calc((var(--badge-container-size) - var(--badge-size)) /2);
top: 0;
position: absolute;
z-index: 2;
opacity: .85;
}
} }
} }
} }

View File

@ -24,6 +24,7 @@ import { AddonBlockCalendarMonthModule } from './calendarmonth/calendarmonth.mod
import { AddonBlockCalendarUpcomingModule } from './calendarupcoming/calendarupcoming.module'; import { AddonBlockCalendarUpcomingModule } from './calendarupcoming/calendarupcoming.module';
import { AddonBlockCommentsModule } from './comments/comments.module'; import { AddonBlockCommentsModule } from './comments/comments.module';
import { AddonBlockCompletionStatusModule } from './completionstatus/completionstatus.module'; import { AddonBlockCompletionStatusModule } from './completionstatus/completionstatus.module';
import { AddonBlockCourseListModule } from './courselist/courselist.module';
import { AddonBlockGlossaryRandomModule } from './glossaryrandom/glossaryrandom.module'; import { AddonBlockGlossaryRandomModule } from './glossaryrandom/glossaryrandom.module';
import { AddonBlockHtmlModule } from './html/html.module'; import { AddonBlockHtmlModule } from './html/html.module';
import { AddonBlockLearningPlansModule } from './learningplans/learningplans.module'; import { AddonBlockLearningPlansModule } from './learningplans/learningplans.module';
@ -53,6 +54,7 @@ import { AddonBlockTimelineModule } from './timeline/timeline.module';
AddonBlockCalendarUpcomingModule, AddonBlockCalendarUpcomingModule,
AddonBlockCommentsModule, AddonBlockCommentsModule,
AddonBlockCompletionStatusModule, AddonBlockCompletionStatusModule,
AddonBlockCourseListModule,
AddonBlockGlossaryRandomModule, AddonBlockGlossaryRandomModule,
AddonBlockHtmlModule, AddonBlockHtmlModule,
AddonBlockLearningPlansModule, AddonBlockLearningPlansModule,

View File

@ -1,9 +1,7 @@
:host .core-block-content ::ng-deep { :host .core-block-content ::ng-deep {
ul.inline-list { ul.inline-list {
font-size: 80%;
list-style: none; list-style: none;
margin-left: 0; margin: 0;
margin-right: 0;
-webkit-padding-start: 0; -webkit-padding-start: 0;
li { li {
@ -11,8 +9,8 @@
display: inline-block; display: inline-block;
a { a {
background: var(--ion-color-primary); background: var(--primary);
color: var(--ion-color-primary-contrast); color: var(--primary-contrast);
padding: 3px 8px; padding: 3px 8px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
display: inline-block; display: inline-block;
@ -24,7 +22,7 @@
contain: content; contain: content;
vertical-align: baseline; vertical-align: baseline;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--small-radius);
} }
.s20 { .s20 {
font-size: 1.5em; font-size: 1.5em;

View File

@ -16,10 +16,10 @@ import { Injectable } from '@angular/core';
import { CoreBlockHandlerData } from '@features/block/services/block-delegate'; import { CoreBlockHandlerData } from '@features/block/services/block-delegate';
import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block'; import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block';
import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler'; import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
import { AddonCalendar } from '@/addons/calendar/services/calendar';
import { CoreCourseBlock } from '@features/course/services/course'; import { CoreCourseBlock } from '@features/course/services/course';
import { Params } from '@angular/router'; import { Params } from '@angular/router';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
import { AddonCalendarMainMenuHandlerService } from '@addons/calendar/services/handlers/mainmenu';
/** /**
* Block handler. * Block handler.
@ -45,7 +45,7 @@ export class AddonBlockCalendarMonthHandlerService extends CoreBlockBaseHandler
title: 'addon.block_calendarmonth.pluginname', title: 'addon.block_calendarmonth.pluginname',
class: 'addon-block-calendar-month', class: 'addon-block-calendar-month',
component: CoreBlockOnlyTitleComponent, component: CoreBlockOnlyTitleComponent,
link: AddonCalendar.getMainCalendarPagePath(), link: AddonCalendarMainMenuHandlerService.PAGE_NAME,
linkParams: linkParams, linkParams: linkParams,
navOptions: { navOptions: {
preferCurrentTab: false, preferCurrentTab: false,

View File

@ -16,10 +16,11 @@ import { Injectable } from '@angular/core';
import { CoreBlockHandlerData } from '@features/block/services/block-delegate'; import { CoreBlockHandlerData } from '@features/block/services/block-delegate';
import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block'; import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block';
import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler'; import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
import { AddonCalendar } from '@/addons/calendar/services/calendar';
import { CoreCourseBlock } from '@features/course/services/course'; import { CoreCourseBlock } from '@features/course/services/course';
import { Params } from '@angular/router'; import { Params } from '@angular/router';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
import { AddonCalendarMainMenuHandlerService } from '@addons/calendar/services/handlers/mainmenu';
import { CoreSites } from '@services/sites';
/** /**
* Block handler. * Block handler.
@ -39,18 +40,18 @@ export class AddonBlockCalendarUpcomingHandlerService extends CoreBlockBaseHandl
* @return Data or promise resolved with the data. * @return Data or promise resolved with the data.
*/ */
getDisplayData(block: CoreCourseBlock, contextLevel: string, instanceId: number): CoreBlockHandlerData { getDisplayData(block: CoreCourseBlock, contextLevel: string, instanceId: number): CoreBlockHandlerData {
const linkParams: Params = contextLevel == 'course' ? { courseId: instanceId } : {}; const linkParams: Params = { upcoming: true };
linkParams.upcoming = true;
if (contextLevel == 'course' && instanceId !== CoreSites.getCurrentSiteHomeId()) {
linkParams.courseId = instanceId;
}
return { return {
title: 'addon.block_calendarupcoming.pluginname', title: 'addon.block_calendarupcoming.pluginname',
class: 'addon-block-calendar-upcoming', class: 'addon-block-calendar-upcoming',
component: CoreBlockOnlyTitleComponent, component: CoreBlockOnlyTitleComponent,
link: AddonCalendar.getMainCalendarPagePath(), link: AddonCalendarMainMenuHandlerService.PAGE_NAME,
linkParams: linkParams, linkParams: linkParams,
navOptions: {
preferCurrentTab: false,
},
}; };
} }

View File

@ -0,0 +1,36 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { IonicModule } from '@ionic/angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreBlockDelegate } from '@features/block/services/block-delegate';
import { AddonBlockCourseListHandler } from './services/block-handler';
@NgModule({
imports: [
IonicModule,
TranslateModule.forChild(),
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
useValue: () => {
CoreBlockDelegate.registerHandler(AddonBlockCourseListHandler.instance);
},
},
],
})
export class AddonBlockCourseListModule {}

View File

@ -0,0 +1,49 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreBlockHandlerData } from '@features/block/services/block-delegate';
import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block';
import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
import { makeSingleton } from '@singletons';
/**
* Block handler.
*/
@Injectable({ providedIn: 'root' })
export class AddonBlockCourseListHandlerService extends CoreBlockBaseHandler {
name = 'AddonBlockCourseList';
blockName = 'course_list';
/**
* @inheritdoc
*/
getDisplayData(): CoreBlockHandlerData {
return {
title: 'core.courses.mycourses',
class: 'addon-block-course-list',
component: CoreBlockOnlyTitleComponent,
link: 'courses/list',
linkParams: { mode: 'my' },
navOptions: {
preferCurrentTab: false,
},
};
}
}
export const AddonBlockCourseListHandler = makeSingleton(AddonBlockCourseListHandlerService);

View File

@ -17,7 +17,7 @@ import { CoreBlockHandlerData } from '@features/block/services/block-delegate';
import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block'; import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block';
import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler'; import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
import { AddonCompetencyMainMenuHandlerService } from '@addons/competency/services/handlers/mainmenu'; import { ADDON_COMPETENCY_LEARNING_PLANS_PAGE } from '@addons/competency/competency.module';
/** /**
* Block handler. * Block handler.
@ -38,7 +38,7 @@ export class AddonBlockLearningPlansHandlerService extends CoreBlockBaseHandler
title: 'addon.block_learningplans.pluginname', title: 'addon.block_learningplans.pluginname',
class: 'addon-block-learning-plans', class: 'addon-block-learning-plans',
component: CoreBlockOnlyTitleComponent, component: CoreBlockOnlyTitleComponent,
link: AddonCompetencyMainMenuHandlerService.PAGE_NAME, link: ADDON_COMPETENCY_LEARNING_PLANS_PAGE,
navOptions: { navOptions: {
preferCurrentTab: false, preferCurrentTab: false,
}, },

View File

@ -4,97 +4,132 @@
</ion-label> </ion-label>
<div slot="end" class="flex-row"> <div slot="end" class="flex-row">
<!-- Download all courses. --> <!-- Download all courses. -->
<div *ngIf="downloadCoursesEnabled && downloadEnabled && filteredCourses.length > 1 && !showFilter" <div *ngIf="downloadCoursesEnabled && filteredCourses.length > 0" class="core-button-spinner">
class="core-button-spinner"> <ion-button *ngIf="!prefetchCoursesData.loading" fill="clear" (click)="prefetchCourses()"
<ion-button *ngIf="!prefetchCoursesData[selectedFilter].loading" fill="clear" color="dark" (click)="prefetchCourses()" [attr.aria-label]="prefetchCoursesData.statusTranslatable | translate">
[attr.aria-label]="'core.courses.downloadcourses' | translate"> <ion-icon [name]="prefetchCoursesData.icon" slot="icon-only" aria-hidden="true">
<ion-icon [name]="prefetchCoursesData[selectedFilter].icon" slot="icon-only" aria-hidden="true">
</ion-icon> </ion-icon>
</ion-button> </ion-button>
<ion-badge class="core-course-download-courses-progress" *ngIf="prefetchCoursesData[selectedFilter].badge" <ion-badge class="core-course-download-courses-progress" *ngIf="prefetchCoursesData.badge" role="progressbar"
role="progressbar" [attr.aria-valuemax]="prefetchCoursesData[selectedFilter].total" [attr.aria-valuemax]="prefetchCoursesData.total" [attr.aria-valuenow]="prefetchCoursesData.count"
[attr.aria-valuenow]="prefetchCoursesData[selectedFilter].count" [attr.aria-valuetext]="prefetchCoursesData.badgeA11yText">
[attr.aria-valuetext]="prefetchCoursesData[selectedFilter].badgeA11yText"> {{prefetchCoursesData.badge}}
{{prefetchCoursesData[selectedFilter].badge}}
</ion-badge> </ion-badge>
<ion-spinner *ngIf="prefetchCoursesData[selectedFilter].loading" [attr.aria-label]="'core.loading' | translate"> <ion-spinner *ngIf="prefetchCoursesData.loading" [attr.aria-label]="'core.loading' | translate">
</ion-spinner> </ion-spinner>
</div> </div>
<core-context-menu>
<core-context-menu-item *ngIf="loaded && showFilterSwitchButton()" [priority]="1000"
[content]="'core.courses.filtermycourses' | translate" (action)="switchFilter()" iconAction="fas-filter"
(onClosed)="switchFilterClosed()"></core-context-menu-item>
<core-context-menu-item *ngIf="loaded && showSortFilter" [priority]="900"
content="{{('core.sortby' | translate) + ' ' + ('addon.block_myoverview.title' | translate)}}"
(action)="switchSort('fullname')" [iconAction]="sort == 'fullname' ? 'far-dot-circle' : 'far-circle'">
</core-context-menu-item>
<core-context-menu-item *ngIf="loaded && showSortFilter && showSortByShortName" [priority]="800"
content="{{('core.sortby' | translate) + ' ' + ('addon.block_myoverview.shortname' | translate)}}"
(action)="switchSort('shortname')" [iconAction]="sort == 'shortname' ? 'far-dot-circle' : 'far-circle'">
</core-context-menu-item>
<core-context-menu-item *ngIf="loaded && showSortFilter" [priority]="700"
content="{{('core.sortby' | translate) + ' ' + ('addon.block_myoverview.lastaccessed' | translate)}}"
(action)="switchSort('lastaccess')" [iconAction]="sort == 'lastaccess' ? 'far-dot-circle' : 'far-circle'">
</core-context-menu-item>
</core-context-menu>
</div> </div>
</ion-item-divider> </ion-item-divider>
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="margin"> <core-loading [hideUntil]="loaded">
<div class="safe-padding-horizontal" [hidden]="showFilter || !showSelectorFilter">
<!-- "Time" selector. --> <ion-row class="ion-hide-md-up addon-block-myoverview-filter" *ngIf="hasCourses">
<core-combobox [label]="'core.show' | translate" [selection]="selectedFilter" (onChange)="selectedChanged($event)"> <ion-col>
<ion-select-option class="ion-text-wrap" value="allincludinghidden" *ngIf="showFilters.allincludinghidden != 'hidden'"> <!-- Filter courses. -->
{{ 'addon.block_myoverview.allincludinghidden' | translate }} <ion-searchbar [(ngModel)]="textFilter" (ionInput)="filterTextChanged($event.target)"
</ion-select-option> (ionCancel)="filterTextChanged($event.target)" [placeholder]="'core.courses.filtermycourses' | translate">
<ion-select-option class="ion-text-wrap" value="all" *ngIf="showFilters.all != 'hidden'"> </ion-searchbar>
{{ 'addon.block_myoverview.all' | translate }} </ion-col>
</ion-select-option> </ion-row>
<ion-select-option class="ion-text-wrap" value="inprogress" *ngIf="showFilters.inprogress != 'hidden'" <ion-row class="ion-justify-content-between ion-align-items-center addon-block-myoverview-filter" *ngIf="hasCourses">
[disabled]="showFilters.inprogress == 'disabled'"> <ion-col size="auto" *ngIf="filters.enabled">
{{ 'addon.block_myoverview.inprogress' | translate }} <core-combobox [label]="'core.courses.filtermycourses' | translate" [selection]="filters.timeFilterSelected"
</ion-select-option> (onChange)="filterOptionsChanged($event)">
<ion-select-option class="ion-text-wrap" value="future" *ngIf="showFilters.future != 'hidden'" <ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="allincludinghidden"
[disabled]="showFilters.future == 'disabled'"> *ngIf="filters.show.allincludinghidden">
{{ 'addon.block_myoverview.future' | translate }} {{ 'addon.block_myoverview.allincludinghidden' | translate }}
</ion-select-option> </ion-select-option>
<ion-select-option class="ion-text-wrap" value="past" *ngIf="showFilters.past != 'hidden'" <ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="all" *ngIf="filters.show.all">
[disabled]="showFilters.past == 'disabled'"> {{ 'addon.block_myoverview.all' | translate }}
{{ 'addon.block_myoverview.past' | translate }} </ion-select-option>
</ion-select-option> <ion-select-option class="ion-text-wrap"
<ng-container *ngIf="showFilters.custom != 'hidden'"> [class.core-select-option-border-bottom]="!filters.show.past && !filters.show.future" value="inprogress"
<ng-container *ngFor="let customOption of customFilter; let index = index"> *ngIf="filters.show.inprogress">
<ion-select-option class="ion-text-wrap" value="custom-{{index}}">{{ customOption.name }}</ion-select-option> {{ 'addon.block_myoverview.inprogress' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" [class.core-select-option-border-bottom]="!filters.show.past" value="future"
*ngIf="filters.show.future">
{{ 'addon.block_myoverview.future' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="past" *ngIf="filters.show.past">
{{ 'addon.block_myoverview.past' | translate }}
</ion-select-option>
<ng-container *ngIf="filters.show.custom">
<ng-container *ngFor="let customOption of filters.customFilters; let index = index; let last = last">
<ion-select-option class="ion-text-wrap" value="custom-{{index}}" [class.core-select-option-border-bottom]="last">
{{ customOption.name }}</ion-select-option>
</ng-container>
</ng-container> </ng-container>
</ng-container> <ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="favourite" *ngIf="filters.show.favourite">
<ion-select-option class="ion-text-wrap" value="favourite" *ngIf="showFilters.favourite != 'hidden'" {{ 'addon.block_myoverview.favourites' | translate }}
[disabled]="showFilters.favourite == 'disabled'"> </ion-select-option>
{{ 'addon.block_myoverview.favourites' | translate }} <ion-select-option class="ion-text-wrap" value="hidden" *ngIf="filters.show.hidden">
</ion-select-option> {{ 'addon.block_myoverview.hiddencourses' | translate }}
<ion-select-option class="ion-text-wrap" value="hidden" *ngIf="showFilters.hidden != 'hidden'" </ion-select-option>
[disabled]="showFilters.hidden == 'disabled'"> </core-combobox>
{{ 'addon.block_myoverview.hiddencourses' | translate }} </ion-col>
</ion-select-option> <ion-col>
</core-combobox> <!-- Filter courses. -->
</div> <ion-searchbar class="ion-hide-md-down" [(ngModel)]="textFilter" (ionInput)="filterTextChanged($event.target)"
(ionCancel)="filterTextChanged($event.target)" [placeholder]="'core.courses.filtermycourses' | translate">
</ion-searchbar>
</ion-col>
<ion-col size="auto" *ngIf="sort.enabled">
<core-combobox [label]="'core.sortby' | translate" [selection]="sort.selected" (onChange)="sortCourses($event)"
icon="fas-sort-amount-down-alt">
<ion-select-option class="ion-text-wrap" value="fullname">
{{'addon.block_myoverview.title' | translate}}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="shortname" *ngIf="sort.shortnameEnabled">
{{'addon.block_myoverview.shortname' | translate}}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="lastaccess">
{{'addon.block_myoverview.lastaccessed' | translate}}
</ion-select-option>
</core-combobox>
</ion-col>
<ion-col size="auto" *ngIf="isLayoutSwitcherAvailable">
<ion-button *ngIf="layout == 'card'" fill="outline" (click)="toggleLayout('list')"
[attr.aria-label]="'addon.block_myoverview.list' | translate">
<ion-icon slot="icon-only" name="fas-list" aria-hidden="true"></ion-icon>
</ion-button>
<ion-button *ngIf="layout == 'list'" fill="outline" (click)="toggleLayout('card')"
[attr.aria-label]="'addon.block_myoverview.card' | translate">
<ion-icon slot="icon-only" name="fas-th" aria-hidden="true"></ion-icon>
</ion-button>
</ion-col>
</ion-row>
<!-- Filter courses. --> <core-empty-box *ngIf="filteredCourses.length == 0" image="assets/img/icons/courses.svg">
<ion-searchbar #searchbar *ngIf="showFilter" [(ngModel)]="courses.filter" (ionInput)="filterChanged($event)" <p *ngIf="hasCourses" class="item-heading">
(ionCancel)="filterChanged($event)" [placeholder]="'core.courses.filtermycourses' | translate"> {{'addon.block_myoverview.noresult' | translate}}
</ion-searchbar> </p>
<p *ngIf="!hasCourses" class="item-heading">
<core-empty-box *ngIf="filteredCourses.length == 0" image="assets/img/icons/courses.svg" {{'addon.block_myoverview.nocoursesenrolled' | translate}}
[message]="'addon.block_myoverview.nocourses' | translate" inline="true"> </p>
<ng-container *ngIf="searchEnabled">
<p *ngIf="hasCourses" class="subdued">
{{'addon.block_myoverview.noresultdescription' | translate}}
</p>
<p *ngIf="!hasCourses" class="subdued">
{{'addon.block_myoverview.nocoursesenrolleddescription' | translate}}
</p>
<ion-button (click)="openSearch()" fill="outline">
<ion-icon name="fas-search" slot="start" aria-hidden="true">
</ion-icon>
{{'addon.block_myoverview.browseallcourses' | translate}}
</ion-button>
</ng-container>
</core-empty-box> </core-empty-box>
<!-- List of courses. --> <!-- List of courses. -->
<div class="safe-area-page"> <div class="safe-area-padding" *ngIf="hasCourses">
<ion-grid class="ion-no-padding"> <ion-grid class="ion-no-padding" [class.core-no-grid]="layout != 'card'" [class.list-item-limited-width]="layout != 'card'">
<ion-row class="ion-no-padding"> <ion-row class="ion-no-padding">
<ion-col *ngFor="let course of filteredCourses" class="ion-no-padding" <ion-col *ngFor="let course of filteredCourses" class="ion-no-padding" size="12" size-sm="6" size-md="6" size-lg="4"
size="12" size-sm="6" size-md="6" size-lg="4" size-xl="3"> size-xl="3">
<core-courses-course-progress [course]="course" class="core-courseoverview" showAll="true" <core-courses-course-list-item [course]="course" class="core-courseoverview" [showDownload]="downloadCourseEnabled"
[showDownload]="downloadCourseEnabled && downloadEnabled"> [layout]="layout">
</core-courses-course-progress> </core-courses-course-list-item>
</ion-col> </ion-col>
</ion-row> </ion-row>
</ion-grid> </ion-grid>

View File

@ -0,0 +1,48 @@
:host {
ion-row.addon-block-myoverview-filter {
margin: 8px;
padding: 0;
ion-col {
padding: 0;
margin-right: 2px;
margin-left: 2px;
}
ion-button,
core-combobox ::ng-deep ion-button {
--border-width: 0;
--a11y-min-target-size: 40px;
margin: 0;
.select-icon {
display: none;
}
ion-icon {
font-size: 20px;
}
}
core-combobox ::ng-deep ion-select {
margin: 0;
--a11y-min-target-size: 40px;
}
ion-searchbar {
padding: 0;
--height: 40px;
}
}
core-empty-box {
.item-heading {
font-weight: bold;
margin-bottom: 0;
font-size: 16px;
}
.subdued {
color: var(--subdued-text-color);
}
}
}

View File

@ -1,12 +1,18 @@
{ {
"all": "All (except removed from view)", "all": "All",
"allincludinghidden": "All", "allincludinghidden": "All (including archived)",
"browseallcourses": "Browse all courses",
"card": "Card",
"favourites": "Starred", "favourites": "Starred",
"future": "Future", "future": "Future",
"hiddencourses": "Removed from view", "hiddencourses": "Archived",
"inprogress": "In progress", "inprogress": "In progress",
"lastaccessed": "Last accessed", "lastaccessed": "Last accessed",
"nocourses": "No courses", "list": "List",
"nocoursesenrolled": "You're not enrolled in any courses yet.",
"nocoursesenrolleddescription": "Browse all available courses below and start learning.",
"noresult": "Your search didn't match any courses.",
"noresultdescription": "Try adjusting your filters or browse all courses below.",
"past": "Past", "past": "Past",
"pluginname": "Course overview", "pluginname": "Course overview",
"shortname": "Short name", "shortname": "Short name",

View File

@ -1,3 +1,5 @@
@import "~theme/globals";
:host .core-block-content ::ng-deep { :host .core-block-content ::ng-deep {
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
@ -17,23 +19,22 @@
list-style-type: none; list-style-type: none;
.user { .user {
float: left; @include float(start);
position: relative; position: relative;
padding-bottom: 16px; padding-bottom: 16px;
.core-adapted-img-container { .core-adapted-img-container {
display: inline; display: inline;
margin-left: 0; @include margin-horizontal(0px, 8px);
margin-right: 8px;
} }
.userpicture { .userpicture {
vertical-align: text-bottom; border-radius: 50%;
} }
} }
.message { .message {
float: right; @include float(end);
margin-top: 3px; margin-top: 3px;
} }
@ -48,20 +49,3 @@
} }
} }
:host-context([dir=rtl]) .core-block-content ::ng-deep {
.list li.listentry {
.user {
float: right;
.core-adapted-img-container {
margin-left: 8px;
margin-right: 0;
}
}
.message {
float: left;
}
}
}

View File

@ -16,7 +16,7 @@ import { Injectable } from '@angular/core';
import { CoreBlockHandlerData } from '@features/block/services/block-delegate'; import { CoreBlockHandlerData } from '@features/block/services/block-delegate';
import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block'; import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block';
import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler'; import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
import { AddonPrivateFilesMainMenuHandlerService } from '@/addons/privatefiles/services/handlers/mainmenu'; import { AddonPrivateFilesUserHandlerService } from '@addons/privatefiles/services/handlers/user';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
/** /**
@ -39,7 +39,7 @@ export class AddonBlockPrivateFilesHandlerService extends CoreBlockBaseHandler {
title: 'addon.block_privatefiles.pluginname', title: 'addon.block_privatefiles.pluginname',
class: 'addon-block-private-files', class: 'addon-block-private-files',
component: CoreBlockOnlyTitleComponent, component: CoreBlockOnlyTitleComponent,
link: AddonPrivateFilesMainMenuHandlerService.PAGE_NAME, link: AddonPrivateFilesUserHandlerService.PAGE_NAME,
linkParams: { root: 'my' }, linkParams: { root: 'my' },
navOptions: { navOptions: {
preferCurrentTab: false, preferCurrentTab: false,

View File

@ -1,3 +1,5 @@
@import "~theme/globals";
:host .core-block-content ::ng-deep { :host .core-block-content ::ng-deep {
.activitydate, .activityhead { .activitydate, .activityhead {
text-align: center; text-align: center;
@ -12,14 +14,8 @@
margin-bottom: 1em; margin-bottom: 1em;
.head .date { .head .date {
float: right; @include float(end);
} }
} }
} }
} }
:host-context([dir=rtl]) .core-block-content ::ng-deep {
.unlist li .head .date {
float: left;
}
}

View File

@ -1,40 +1,25 @@
<ion-item-divider sticky="true"> <ion-item-divider sticky="true">
<ion-label> <ion-label>
<h2>{{ 'addon.block_recentlyaccessedcourses.pluginname' | translate }}</h2> <h2>{{ 'addon.block_recentlyaccessedcourses.pluginname' | translate }}</h2>
</ion-label> </ion-label>
<div slot="end" class="flex-row"> <div slot="end" class="flex-row">
<div *ngIf="downloadCoursesEnabled && downloadEnabled && courses && courses.length > 1" class="core-button-spinner">
<ion-button *ngIf="prefetchCoursesData.icon && !prefetchCoursesData.loading" fill="clear" color="dark"
(click)="prefetchCourses()" [attr.aria-label]="'core.courses.downloadcourses' | translate">
<ion-icon [name]="prefetchCoursesData.icon" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<ion-badge class="core-course-download-courses-progress" *ngIf="prefetchCoursesData.badge"
role="progressbar" [attr.aria-valuemax]="prefetchCoursesData.total"
[attr.aria-valuenow]="prefetchCoursesData.count" [attr.aria-valuetext]="prefetchCoursesData.badgeA11yText">
{{prefetchCoursesData.badge}}
</ion-badge>
<ion-spinner *ngIf="!prefetchCoursesData.icon || prefetchCoursesData.loading"
[attr.aria-label]="'core.loading' | translate"></ion-spinner>
</div>
<core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId"> <core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId">
</core-horizontal-scroll-controls> </core-horizontal-scroll-controls>
</div> </div>
</ion-item-divider> </ion-item-divider>
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="safe-area-page margin"> <core-loading [hideUntil]="loaded">
<core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg" inline="true" <core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg"
[message]="'addon.block_recentlyaccessedcourses.nocourses' | translate"></core-empty-box> [message]="'addon.block_recentlyaccessedcourses.nocourses' | translate"></core-empty-box>
<!-- List of courses. --> <!-- List of courses. -->
<div <div [hidden]="courses.length === 0" [id]="scrollElementId" class="core-horizontal-scroll"
[id]="scrollElementId" (scroll)="scrollControls.updateScrollPosition()">
class="core-horizontal-scroll"
(scroll)="scrollControls.updateScrollPosition()"
>
<div (onResize)="scrollControls.updateScrollPosition()" class="flex-row"> <div (onResize)="scrollControls.updateScrollPosition()" class="flex-row">
<div class="safe-area-pseudo-padding-start"></div>
<ng-container *ngFor="let course of courses"> <ng-container *ngFor="let course of courses">
<core-courses-course-progress [course]="course" class="core-recentlyaccessedcourses" <core-courses-course-list-item [course]="course" class="core-recentlyaccessedcourses" layout="summarycard">
[showDownload]="downloadCourseEnabled && downloadEnabled"></core-courses-course-progress> </core-courses-course-list-item>
</ng-container> </ng-container>
<div class="safe-area-pseudo-padding-end"></div>
</div> </div>
</div> </div>
</core-loading> </core-loading>

View File

@ -12,17 +12,25 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { Component, OnInit, OnDestroy, Input, OnChanges, SimpleChange } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreCoursesProvider, CoreCoursesMyCoursesUpdatedEventData, CoreCourses } from '@features/courses/services/courses'; import {
import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; CoreCoursesProvider,
import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course-helper'; CoreCoursesMyCoursesUpdatedEventData,
CoreCourses,
CoreCourseSummaryData,
} from '@features/courses/services/courses';
import {
CoreCourseSearchedDataWithExtraInfoAndOptions,
CoreCoursesHelper,
CoreEnrolledCourseDataWithOptions,
} from '@features/courses/services/courses-helper';
import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate';
import { AddonCourseCompletion } from '@/addons/coursecompletion/services/coursecompletion'; import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion';
import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreSite } from '@classes/site';
/** /**
* Component to render a recent courses block. * Component to render a recent courses block.
@ -31,36 +39,25 @@ import { CoreDomUtils } from '@services/utils/dom';
selector: 'addon-block-recentlyaccessedcourses', selector: 'addon-block-recentlyaccessedcourses',
templateUrl: 'addon-block-recentlyaccessedcourses.html', templateUrl: 'addon-block-recentlyaccessedcourses.html',
}) })
export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseComponent implements OnInit, OnChanges, OnDestroy { export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseComponent implements OnInit, OnDestroy {
@Input() downloadEnabled = false; courses: AddonBlockRecentlyAccessedCourse[] = [];
courses: CoreEnrolledCourseDataWithOptions [] = [];
prefetchCoursesData: CorePrefetchStatusInfo = {
icon: '',
statusTranslatable: 'core.loading',
status: '',
loading: true,
badge: '',
};
downloadCourseEnabled = false;
downloadCoursesEnabled = false;
scrollElementId!: string; scrollElementId!: string;
protected prefetchIconsInitialized = false; protected site!: CoreSite;
protected isDestroyed = false; protected isDestroyed = false;
protected coursesObserver?: CoreEventObserver; protected coursesObserver?: CoreEventObserver;
protected updateSiteObserver?: CoreEventObserver;
protected courseIds = [];
protected fetchContentDefaultError = 'Error getting recent courses data.'; protected fetchContentDefaultError = 'Error getting recent courses data.';
constructor() { constructor() {
super('AddonBlockRecentlyAccessedCoursesComponent'); super('AddonBlockRecentlyAccessedCoursesComponent');
this.site = CoreSites.getRequiredCurrentSite();
} }
/** /**
* Component being initialized. * @inheritdoc
*/ */
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
// Generate unique id for scroll element. // Generate unique id for scroll element.
@ -68,175 +65,149 @@ export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseCom
this.scrollElementId = `addon-block-recentlyaccessedcourses-scroll-${scrollId}`; this.scrollElementId = `addon-block-recentlyaccessedcourses-scroll-${scrollId}`;
// Refresh the enabled flags if enabled.
this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite();
// Refresh the enabled flags if site is updated.
this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite();
}, CoreSites.getCurrentSiteId());
this.coursesObserver = CoreEvents.on( this.coursesObserver = CoreEvents.on(
CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, CoreCoursesProvider.EVENT_MY_COURSES_UPDATED,
(data) => { (data) => {
this.refreshCourseList(data);
if (this.shouldRefreshOnUpdatedEvent(data)) {
this.refreshCourseList();
}
}, },
this.site.getId(),
CoreSites.getCurrentSiteId(),
); );
super.ngOnInit(); super.ngOnInit();
} }
/** /**
* Detect changes on input properties. * @inheritdoc
*/
ngOnChanges(changes: {[name: string]: SimpleChange}): void {
if (changes.downloadEnabled && !changes.downloadEnabled.previousValue && this.downloadEnabled && this.loaded) {
// Download all courses is enabled now, initialize it.
this.initPrefetchCoursesIcons();
}
}
/**
* Perform the invalidate content function.
*
* @return Resolved when done.
*/ */
protected async invalidateContent(): Promise<void> { protected async invalidateContent(): Promise<void> {
const promises: Promise<void>[] = []; const courseIds = this.courses.map((course) => course.id);
promises.push(CoreCourses.invalidateUserCourses().finally(() => await this.invalidateCourses(courseIds);
// Invalidate course completion data.
CoreUtils.allPromises(this.courseIds.map((courseId) =>
AddonCourseCompletion.invalidateCourseCompletion(courseId)))));
promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions());
if (this.courseIds.length > 0) {
promises.push(CoreCourses.invalidateCoursesByField('ids', this.courseIds.join(',')));
}
await CoreUtils.allPromises(promises).finally(() => {
this.prefetchIconsInitialized = false;
});
} }
/** /**
* Fetch the courses for recent courses. * Invalidate list of courses.
* *
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
protected async invalidateCourseList(): Promise<void> {
return this.site.isVersionGreaterEqualThan('3.8')
? CoreCourses.invalidateRecentCourses()
: CoreCourses.invalidateUserCourses();
}
/**
* Helper function to invalidate only selected courses.
*
* @param courseIds Course Id array.
* @return Promise resolved when done.
*/
protected async invalidateCourses(courseIds: number[]): Promise<void> {
const promises: Promise<void>[] = [];
// Invalidate course completion data.
promises.push(this.invalidateCourseList().finally(() =>
CoreUtils.allPromises(courseIds.map((courseId) =>
AddonCourseCompletion.invalidateCourseCompletion(courseId)))));
if (courseIds.length == 1) {
promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions(courseIds[0]));
} else {
promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions());
}
if (courseIds.length > 0) {
promises.push(CoreCourses.invalidateCoursesByField('ids', courseIds.join(',')));
}
await CoreUtils.allPromises(promises);
}
/**
* @inheritdoc
*/
protected async fetchContent(): Promise<void> { protected async fetchContent(): Promise<void> {
const showCategories = this.block.configsRecord && this.block.configsRecord.displaycategories && const showCategories = this.block.configsRecord && this.block.configsRecord.displaycategories &&
this.block.configsRecord.displaycategories.value == '1'; this.block.configsRecord.displaycategories.value == '1';
this.courses = await CoreCoursesHelper.getUserCoursesWithOptions('lastaccess', 10, undefined, showCategories); let recentCourses: CoreCourseSummaryData[] = [];
this.initPrefetchCoursesIcons();
}
/**
* Refresh the list of courses.
*
* @return Promise resolved when done.
*/
protected async refreshCourseList(): Promise<void> {
CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_REFRESHED);
try { try {
await CoreCourses.invalidateUserCourses(); recentCourses = await CoreCourses.getRecentCourses();
} catch (error) { } catch {
// Ignore errors. // WS is failing on 3.7 and bellow, use a fallback.
} this.courses = await CoreCoursesHelper.getUserCoursesWithOptions('lastaccess', 10, undefined, showCategories);
await this.loadContent(true);
}
/**
* Initialize the prefetch icon for selected courses.
*/
protected async initPrefetchCoursesIcons(): Promise<void> {
if (this.prefetchIconsInitialized || !this.downloadEnabled) {
// Already initialized.
return; return;
} }
this.prefetchIconsInitialized = true; const courseIds = recentCourses.map((course) => course.id);
this.prefetchCoursesData = await CoreCourseHelper.initPrefetchCoursesIcons(this.courses, this.prefetchCoursesData); // Get the courses using getCoursesByField to get more info about each course.
const courses = await CoreCourses.getCoursesByField('ids', courseIds.join(','));
this.courses = recentCourses.map((recentCourse) => {
const course = courses.find((course) => recentCourse.id == course.id);
return Object.assign(recentCourse, course);
});
// Get course options and extra info.
const options = await CoreCourses.getCoursesAdminAndNavOptions(courseIds);
this.courses.forEach((course) => {
course.navOptions = options.navOptions[course.id];
course.admOptions = options.admOptions[course.id];
if (!showCategories) {
course.categoryname = '';
}
});
} }
/** /**
* Whether list should be refreshed based on a EVENT_MY_COURSES_UPDATED event. * Refresh course list based on a EVENT_MY_COURSES_UPDATED event.
* *
* @param data Event data. * @param data Event data.
* @return Whether to refresh.
*/
protected shouldRefreshOnUpdatedEvent(data: CoreCoursesMyCoursesUpdatedEventData): boolean {
if (data.action == CoreCoursesProvider.ACTION_ENROL) {
// Always update if user enrolled in a course.
return true;
}
if (data.action == CoreCoursesProvider.ACTION_VIEW && data.courseId != CoreSites.getCurrentSiteHomeId() &&
this.courses[0] && data.courseId != this.courses[0].id) {
// Update list if user viewed a course that isn't the most recent one and isn't site home.
return true;
}
if (data.action == CoreCoursesProvider.ACTION_STATE_CHANGED && data.state == CoreCoursesProvider.STATE_FAVOURITE &&
data.courseId && this.hasCourse(data.courseId)) {
// Update list if a visible course is now favourite or unfavourite.
return true;
}
return false;
}
/**
* Check if a certain course is in the list of courses.
*
* @param courseId Course ID to search.
* @return Whether it's in the list.
*/
protected hasCourse(courseId: number): boolean {
if (!this.courses) {
return false;
}
return !!this.courses.find((course) => course.id == courseId);
}
/**
* Prefetch all the shown courses.
*
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
async prefetchCourses(): Promise<void> { protected async refreshCourseList(data: CoreCoursesMyCoursesUpdatedEventData): Promise<void> {
const initialIcon = this.prefetchCoursesData.icon; if (data.action == CoreCoursesProvider.ACTION_ENROL) {
// Always update if user enrolled in a course.
return await this.refreshContent();
}
try { const courseIndex = this.courses.findIndex((course) => course.id == data.courseId);
await CoreCourseHelper.prefetchCourses(this.courses, this.prefetchCoursesData); const course = this.courses[courseIndex];
} catch (error) { if (data.action == CoreCoursesProvider.ACTION_VIEW && data.courseId != CoreSites.getCurrentSiteHomeId()) {
if (!this.isDestroyed) { if (!course) {
CoreDomUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); // Not found, use WS update.
this.prefetchCoursesData.icon = initialIcon; return await this.refreshContent();
} }
// Place at the begining.
this.courses.splice(courseIndex, 1);
this.courses.unshift(course);
await this.invalidateCourseList();
}
if (data.action == CoreCoursesProvider.ACTION_STATE_CHANGED &&
data.state == CoreCoursesProvider.STATE_FAVOURITE && course) {
course.isfavourite = !!data.value;
await this.invalidateCourseList();
} }
} }
/** /**
* Component being destroyed. * @inheritdoc
*/ */
ngOnDestroy(): void { ngOnDestroy(): void {
this.isDestroyed = true; this.isDestroyed = true;
this.coursesObserver?.off(); this.coursesObserver?.off();
this.updateSiteObserver?.off();
} }
} }
type AddonBlockRecentlyAccessedCourse =
(Omit<CoreCourseSummaryData, 'visible'> & CoreCourseSearchedDataWithExtraInfoAndOptions) |
(CoreEnrolledCourseDataWithOptions & {
categoryname?: string; // Category name,
});

View File

@ -1,23 +1,23 @@
<ion-item-divider sticky="true"> <ion-item-divider sticky="true">
<ion-label><h2>{{ 'addon.block_recentlyaccesseditems.pluginname' | translate }}</h2></ion-label> <ion-label>
<h2>{{ 'addon.block_recentlyaccesseditems.pluginname' | translate }}</h2>
</ion-label>
<div slot="end"> <div slot="end">
<core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId"> <core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId">
</core-horizontal-scroll-controls> </core-horizontal-scroll-controls>
</div> </div>
</ion-item-divider> </ion-item-divider>
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="safe-area-page"> <core-loading [hideUntil]="loaded">
<div <div [id]="scrollElementId" [hidden]="!items || items.length === 0" class="core-horizontal-scroll"
[id]="scrollElementId" (scroll)="scrollControls.updateScrollPosition()">
[hidden]="!items || items.length === 0"
class="core-horizontal-scroll"
(scroll)="scrollControls.updateScrollPosition()"
>
<div *ngIf="items" (onResize)="scrollControls.updateScrollPosition()" class="flex-row"> <div *ngIf="items" (onResize)="scrollControls.updateScrollPosition()" class="flex-row">
<div *ngFor="let item of items"> <div class="safe-area-pseudo-padding-start"></div>
<div *ngFor="let item of items" class="core-horizontal-scroll-item">
<ion-card> <ion-card>
<ion-item class="core-course-module-handler item-media ion-text-wrap" detail="false" (click)="action($event, item)" <ion-item class="core-course-module-handler ion-text-wrap" detail="false" (click)="action($event, item)" button>
button> <core-mod-icon slot="start" *ngIf="item.iconUrl" [modicon]="item.iconUrl" [modname]="item.modname"
<img slot="start" [src]="item.iconUrl" alt="" role="presentation" *ngIf="item.iconUrl" class="core-module-icon"> [componentId]="item.cmid" [showAlt]="false">
</core-mod-icon>
<ion-label> <ion-label>
<!-- Add the icon title so accessibility tools read it. --> <!-- Add the icon title so accessibility tools read it. -->
<span class="sr-only" *ngIf="item.iconTitle">{{ item.iconTitle }}</span> <span class="sr-only" *ngIf="item.iconTitle">{{ item.iconTitle }}</span>
@ -33,10 +33,11 @@
</ion-item> </ion-item>
</ion-card> </ion-card>
</div> </div>
<div class="safe-area-pseudo-padding-end"></div>
</div> </div>
</div> </div>
<core-empty-box *ngIf="items.length <= 0" image="assets/img/icons/activities.svg" inline="true" <core-empty-box *ngIf="items.length <= 0" image="assets/img/icons/activities.svg"
[message]="'addon.block_recentlyaccesseditems.noitems' | translate"></core-empty-box> [message]="'addon.block_recentlyaccesseditems.noitems' | translate"></core-empty-box>
</core-loading> </core-loading>

View File

@ -1,12 +1,23 @@
@import "~theme/globals"; @import "~theme/globals";
:host { :host {
.core-horizontal-scroll > div > div { .core-horizontal-scroll div.core-horizontal-scroll-item {
@include horizontal_scroll_item(80%, 250px, 300px); @include horizontal_scroll_item(80%, 250px, 300px);
ion-card {
height: auto;
}
.ion-text-wrap ion-label {
.item-heading, h2, p {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
} }
.core-course-module-handler { .core-course-module-handler {
--inner-border-width: 0; --inner-border-width: 0px;
} }
core-loading { core-loading {
--loading-inline-min-height: 102px; --loading-inline-min-height: 102px;

View File

@ -49,17 +49,41 @@ export class AddonBlockRecentlyAccessedItemsProvider {
cacheKey: this.getRecentItemsCacheKey(), cacheKey: this.getRecentItemsCacheKey(),
}; };
const items: AddonBlockRecentlyAccessedItemsItem[] = let items: AddonBlockRecentlyAccessedItemsItem[] =
await site.read('block_recentlyaccesseditems_get_recent_items', undefined, preSets); await site.read('block_recentlyaccesseditems_get_recent_items', undefined, preSets);
return items.map((item) => { const cmIds: number[] = [];
items = await Promise.all(items.map(async (item) => {
const modicon = item.icon && CoreDomUtils.getHTMLElementAttribute(item.icon, 'src'); const modicon = item.icon && CoreDomUtils.getHTMLElementAttribute(item.icon, 'src');
item.iconUrl = CoreCourse.getModuleIconSrc(item.modname, modicon || undefined); item.iconUrl = await CoreCourse.getModuleIconSrc(item.modname, modicon || undefined);
item.iconTitle = item.icon && CoreDomUtils.getHTMLElementAttribute(item.icon, 'title'); item.iconTitle = item.icon && CoreDomUtils.getHTMLElementAttribute(item.icon, 'title');
cmIds.push(item.cmid);
return item; return item;
}));
// Check if the viewed module should be updated for each activity.
const lastViewedMap = await CoreCourse.getCertainModulesViewed(cmIds, site.getId());
items.forEach((recentItem) => {
const timeAccess = recentItem.timeaccess * 1000;
const lastViewed = lastViewedMap[recentItem.cmid];
if (lastViewed && lastViewed.timeaccess >= timeAccess) {
return; // No need to update.
}
// Update access.
CoreCourse.storeModuleViewed(recentItem.courseid, recentItem.cmid, {
timeaccess: recentItem.timeaccess * 1000,
sectionId: lastViewed && lastViewed.sectionId,
siteId: site.getId(),
});
}); });
return items;
} }
/** /**

View File

@ -6,7 +6,7 @@
-webkit-padding-start: 0; -webkit-padding-start: 0;
li { li {
border-top: 1px solid var(--gray); border-top: 1px solid var(--stroke);
padding: 5px; padding: 5px;
padding-bottom: 8px; padding-bottom: 8px;
} }

View File

@ -1,18 +1,17 @@
<ion-item-divider sticky="true"> <ion-item-divider sticky="true">
<ion-label> <ion-label>
<h2>{{ 'addon.block_sitemainmenu.pluginname' | translate }}</h2> <h2>{{ 'addon.block_sitemainmenu.pluginname' | translate }}</h2>
</ion-label> </ion-label>
</ion-item-divider> </ion-item-divider>
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="margin"> <core-loading [hideUntil]="loaded">
<ng-container *ngIf="mainMenuBlock"> <ion-list *ngIf="mainMenuBlock" class="core-course-module-list-wrapper">
<ion-item class="ion-text-wrap" *ngIf="mainMenuBlock.summary"> <ion-item class="ion-text-wrap" *ngIf="mainMenuBlock.summary">
<ion-label> <ion-label>
<core-format-text [text]="mainMenuBlock.summary" [component]="component" [componentId]="siteHomeId" <core-format-text [text]="mainMenuBlock.summary" [component]="component" [componentId]="siteHomeId" contextLevel="course"
contextLevel="course" [contextInstanceId]="siteHomeId"></core-format-text> [contextInstanceId]="siteHomeId"></core-format-text>
</ion-label> </ion-label>
</ion-item> </ion-item>
<core-course-module *ngFor="let module of mainMenuBlock.modules" [module]="module" [courseId]="siteHomeId" <core-course-module *ngFor="let module of mainMenuBlock.modules" [module]="module" [section]="mainMenuBlock"></core-course-module>
[downloadEnabled]="downloadEnabled" [section]="mainMenuBlock"></core-course-module> </ion-list>
</ng-container>
</core-loading> </core-loading>

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { Component, OnInit, Input } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreCourse } from '@features/course/services/course'; import { CoreCourse } from '@features/course/services/course';
import { CoreCourseHelper, CoreCourseSection } from '@features/course/services/course-helper'; import { CoreCourseHelper, CoreCourseSection } from '@features/course/services/course-helper';
@ -29,8 +29,6 @@ import { CoreBlockBaseComponent } from '@features/block/classes/base-block-compo
}) })
export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent implements OnInit { export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent implements OnInit {
@Input() downloadEnabled = false;
component = 'AddonBlockSiteMainMenu'; component = 'AddonBlockSiteMainMenu';
mainMenuBlock?: CoreCourseSection; mainMenuBlock?: CoreCourseSection;
siteHomeId = 1; siteHomeId = 1;
@ -91,7 +89,7 @@ export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent impl
const items = config.frontpageloggedin.split(','); const items = config.frontpageloggedin.split(',');
const hasNewsItem = items.find((item) => parseInt(item, 10) == FrontPageItemNames['NEWS_ITEMS']); const hasNewsItem = items.find((item) => parseInt(item, 10) == FrontPageItemNames['NEWS_ITEMS']);
const result = CoreCourseHelper.addHandlerDataForModules( const result = await CoreCourseHelper.addHandlerDataForModules(
[mainMenuBlock], [mainMenuBlock],
this.siteHomeId, this.siteHomeId,
undefined, undefined,

View File

@ -1,41 +1,25 @@
<ion-item-divider sticky="true"> <ion-item-divider sticky="true">
<ion-label> <ion-label>
<h2>{{ 'addon.block_starredcourses.pluginname' | translate }}</h2> <h2>{{ 'addon.block_starredcourses.pluginname' | translate }}</h2>
</ion-label> </ion-label>
<div slot="end" class="flex-row"> <div slot="end" class="flex-row">
<div *ngIf="downloadCoursesEnabled && downloadEnabled && courses && courses.length > 1" class="core-button-spinner">
<ion-button *ngIf="prefetchCoursesData.icon && !prefetchCoursesData.loading" fill="clear" color="dark"
(click)="prefetchCourses()" [attr.aria-label]="'core.courses.downloadcourses' | translate">
<ion-icon [name]="prefetchCoursesData.icon" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<ion-badge class="core-course-download-courses-progress" *ngIf="prefetchCoursesData.badge"
role="progressbar" [attr.aria-valuemax]="prefetchCoursesData.total"
[attr.aria-valuenow]="prefetchCoursesData.count" [attr.aria-valuetext]="prefetchCoursesData.badgeA11yText">
{{prefetchCoursesData.badge}}
</ion-badge>
<ion-spinner *ngIf="!prefetchCoursesData.icon || prefetchCoursesData.loading"
[attr.aria-label]="'core.loading' | translate"></ion-spinner>
</div>
<core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId"> <core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId">
</core-horizontal-scroll-controls> </core-horizontal-scroll-controls>
</div> </div>
</ion-item-divider> </ion-item-divider>
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="safe-area-page margin"> <core-loading [hideUntil]="loaded">
<core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg" inline="true" <core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg"
[message]="'addon.block_starredcourses.nocourses' | translate"></core-empty-box> [message]="'addon.block_starredcourses.nocourses' | translate"></core-empty-box>
<!-- List of courses. --> <!-- List of courses. -->
<div <div [hidden]="courses.length === 0" [id]="scrollElementId" class="core-horizontal-scroll"
[hidden]="courses.length === 0" (scroll)="scrollControls.updateScrollPosition()">
[id]="scrollElementId"
class="core-horizontal-scroll"
(scroll)="scrollControls.updateScrollPosition()"
>
<div (onResize)="scrollControls.updateScrollPosition()" class="flex-row"> <div (onResize)="scrollControls.updateScrollPosition()" class="flex-row">
<div class="safe-area-pseudo-padding-start"></div>
<ng-container *ngFor="let course of courses"> <ng-container *ngFor="let course of courses">
<core-courses-course-progress [course]="course" class="core-block_starredcourses" <core-courses-course-list-item [course]="course" class="core-block_starredcourses" layout="summarycard">
[showDownload]="downloadCourseEnabled && downloadEnabled"></core-courses-course-progress> </core-courses-course-list-item>
</ng-container> </ng-container>
<div class="safe-area-pseudo-padding-end"></div>
</div> </div>
</div> </div>
</core-loading> </core-loading>

View File

@ -12,17 +12,20 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { Component, OnInit, OnDestroy, Input, OnChanges, SimpleChange } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreCoursesProvider, CoreCoursesMyCoursesUpdatedEventData, CoreCourses } from '@features/courses/services/courses'; import { CoreCoursesProvider, CoreCoursesMyCoursesUpdatedEventData, CoreCourses } from '@features/courses/services/courses';
import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; import {
import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course-helper'; CoreCourseSearchedDataWithExtraInfoAndOptions,
CoreEnrolledCourseDataWithOptions,
} from '@features/courses/services/courses-helper';
import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate';
import { AddonCourseCompletion } from '@/addons/coursecompletion/services/coursecompletion'; import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion';
import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreSite } from '@classes/site';
import { AddonBlockStarredCourse, AddonBlockStarredCourses } from '../../services/starredcourses';
/** /**
* Component to render a starred courses block. * Component to render a starred courses block.
@ -31,36 +34,25 @@ import { CoreDomUtils } from '@services/utils/dom';
selector: 'addon-block-starredcourses', selector: 'addon-block-starredcourses',
templateUrl: 'addon-block-starredcourses.html', templateUrl: 'addon-block-starredcourses.html',
}) })
export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent implements OnInit, OnChanges, OnDestroy { export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent implements OnInit, OnDestroy {
@Input() downloadEnabled = false; courses: AddonBlockStarredCoursesCourse[] = [];
courses: CoreEnrolledCourseDataWithOptions [] = [];
prefetchCoursesData: CorePrefetchStatusInfo = {
icon: '',
statusTranslatable: 'core.loading',
status: '',
loading: true,
badge: '',
};
downloadCourseEnabled = false;
downloadCoursesEnabled = false;
scrollElementId!: string; scrollElementId!: string;
protected prefetchIconsInitialized = false; protected site: CoreSite;
protected isDestroyed = false; protected isDestroyed = false;
protected coursesObserver?: CoreEventObserver; protected coursesObserver?: CoreEventObserver;
protected updateSiteObserver?: CoreEventObserver;
protected courseIds: number[] = [];
protected fetchContentDefaultError = 'Error getting starred courses data.'; protected fetchContentDefaultError = 'Error getting starred courses data.';
constructor() { constructor() {
super('AddonBlockStarredCoursesComponent'); super('AddonBlockStarredCoursesComponent');
this.site = CoreSites.getRequiredCurrentSite();
} }
/** /**
* Component being initialized. * @inheritdoc
*/ */
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
// Generate unique id for scroll element. // Generate unique id for scroll element.
@ -68,25 +60,10 @@ export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent im
this.scrollElementId = `addon-block-starredcourses-scroll-${scrollId}`; this.scrollElementId = `addon-block-starredcourses-scroll-${scrollId}`;
// Refresh the enabled flags if enabled.
this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite();
// Refresh the enabled flags if site is updated.
this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite();
}, CoreSites.getCurrentSiteId());
this.coursesObserver = CoreEvents.on( this.coursesObserver = CoreEvents.on(
CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, CoreCoursesProvider.EVENT_MY_COURSES_UPDATED,
(data) => { (data) => {
this.refreshCourseList(data);
if (this.shouldRefreshOnUpdatedEvent(data)) {
this.refreshCourseList();
}
this.refreshContent();
}, },
CoreSites.getCurrentSiteId(), CoreSites.getCurrentSiteId(),
@ -96,128 +73,130 @@ export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent im
} }
/** /**
* Detect changes on input properties. * @inheritdoc
*/
ngOnChanges(changes: {[name: string]: SimpleChange}): void {
if (changes.downloadEnabled && !changes.downloadEnabled.previousValue && this.downloadEnabled && this.loaded) {
// Download all courses is enabled now, initialize it.
this.initPrefetchCoursesIcons();
}
}
/**
* Perform the invalidate content function.
*
* @return Resolved when done.
*/ */
protected async invalidateContent(): Promise<void> { protected async invalidateContent(): Promise<void> {
const promises: Promise<void>[] = []; const courseIds = this.courses.map((course) => course.id);
promises.push(CoreCourses.invalidateUserCourses().finally(() => await this.invalidateCourses(courseIds);
// Invalidate course completion data.
CoreUtils.allPromises(this.courseIds.map((courseId) =>
AddonCourseCompletion.invalidateCourseCompletion(courseId)))));
promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions());
if (this.courseIds.length > 0) {
promises.push(CoreCourses.invalidateCoursesByField('ids', this.courseIds.join(',')));
}
await CoreUtils.allPromises(promises).finally(() => {
this.prefetchIconsInitialized = false;
});
} }
/** /**
* Fetch the courses. * Invalidate list of courses.
* *
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
protected async invalidateCourseList(): Promise<void> {
return AddonBlockStarredCourses.invalidateStarredCourses();
}
/**
* Helper function to invalidate only selected courses.
*
* @param courseIds Course Id array.
* @return Promise resolved when done.
*/
protected async invalidateCourses(courseIds: number[]): Promise<void> {
const promises: Promise<void>[] = [];
// Invalidate course completion data.
promises.push(this.invalidateCourseList().finally(() =>
CoreUtils.allPromises(courseIds.map((courseId) =>
AddonCourseCompletion.invalidateCourseCompletion(courseId)))));
if (courseIds.length == 1) {
promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions(courseIds[0]));
} else {
promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions());
}
if (courseIds.length > 0) {
promises.push(CoreCourses.invalidateCoursesByField('ids', courseIds.join(',')));
}
await CoreUtils.allPromises(promises);
}
/**
* @inheritdoc
*/
protected async fetchContent(): Promise<void> { protected async fetchContent(): Promise<void> {
const showCategories = this.block.configsRecord && this.block.configsRecord.displaycategories && const showCategories = this.block.configsRecord && this.block.configsRecord.displaycategories &&
this.block.configsRecord.displaycategories.value == '1'; this.block.configsRecord.displaycategories.value == '1';
this.courses = await CoreCoursesHelper.getUserCoursesWithOptions('timemodified', 0, 'isfavourite', showCategories); // Timemodified not present, use the block WS to retrieve the info.
this.initPrefetchCoursesIcons(); const starredCourses = await AddonBlockStarredCourses.getStarredCourses();
const courseIds = starredCourses.map((course) => course.id);
// Get the courses using getCoursesByField to get more info about each course.
const courses = await CoreCourses.getCoursesByField('ids', courseIds.join(','));
this.courses = starredCourses.map((recentCourse) => {
const course = courses.find((course) => recentCourse.id == course.id);
return Object.assign(recentCourse, course);
});
// Get course options and extra info.
const options = await CoreCourses.getCoursesAdminAndNavOptions(courseIds);
this.courses.forEach((course) => {
course.navOptions = options.navOptions[course.id];
course.admOptions = options.admOptions[course.id];
if (!showCategories) {
course.categoryname = '';
}
});
} }
/** /**
* Refresh the list of courses. * Refresh course list based on a EVENT_MY_COURSES_UPDATED event.
*
* @return Promise resolved when done.
*/
protected async refreshCourseList(): Promise<void> {
CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_REFRESHED);
try {
await CoreCourses.invalidateUserCourses();
} catch (error) {
// Ignore errors.
}
await this.loadContent(true);
}
/**
* Whether list should be refreshed based on a EVENT_MY_COURSES_UPDATED event.
* *
* @param data Event data. * @param data Event data.
* @return Whether to refresh. * @return Promise resolved when done.
*/ */
protected shouldRefreshOnUpdatedEvent(data: CoreCoursesMyCoursesUpdatedEventData): boolean { protected async refreshCourseList(data: CoreCoursesMyCoursesUpdatedEventData): Promise<void> {
if (data.action == CoreCoursesProvider.ACTION_ENROL) { if (data.action == CoreCoursesProvider.ACTION_ENROL) {
// Always update if user enrolled in a course. // Always update if user enrolled in a course.
// New courses shouldn't be favourite by default, but just in case. // New courses shouldn't be favourite by default, but just in case.
return true; return await this.refreshContent();
} }
if (data.action == CoreCoursesProvider.ACTION_STATE_CHANGED && data.state == CoreCoursesProvider.STATE_FAVOURITE) { if (data.action == CoreCoursesProvider.ACTION_STATE_CHANGED && data.state == CoreCoursesProvider.STATE_FAVOURITE) {
// Update list when making a course favourite or not. const courseIndex = this.courses.findIndex((course) => course.id == data.courseId);
return true; if (courseIndex < 0) {
} // Not found, use WS update. Usually new favourite.
return await this.refreshContent();
return false;
}
/**
* Initialize the prefetch icon for selected courses.
*/
protected async initPrefetchCoursesIcons(): Promise<void> {
if (this.prefetchIconsInitialized || !this.downloadEnabled) {
// Already initialized.
return;
}
this.prefetchIconsInitialized = true;
this.prefetchCoursesData = await CoreCourseHelper.initPrefetchCoursesIcons(this.courses, this.prefetchCoursesData);
}
/**
* Prefetch all the shown courses.
*
* @return Promise resolved when done.
*/
async prefetchCourses(): Promise<void> {
const initialIcon = this.prefetchCoursesData.icon;
try {
return CoreCourseHelper.prefetchCourses(this.courses, this.prefetchCoursesData);
} catch (error) {
if (!this.isDestroyed) {
CoreDomUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true);
this.prefetchCoursesData.icon = initialIcon;
} }
const course = this.courses[courseIndex];
if (data.value === false) {
// Unfavourite, just remove.
this.courses.splice(courseIndex, 1);
} else {
// List is not synced, favourite course and place it at the begining.
course.isfavourite = !!data.value;
this.courses.splice(courseIndex, 1);
this.courses.unshift(course);
}
await this.invalidateCourseList();
} }
} }
/** /**
* Component being destroyed. * @inheritdoc
*/ */
ngOnDestroy(): void { ngOnDestroy(): void {
this.isDestroyed = true; this.isDestroyed = true;
this.coursesObserver?.off(); this.coursesObserver?.off();
this.updateSiteObserver?.off();
} }
} }
type AddonBlockStarredCoursesCourse =
(AddonBlockStarredCourse & CoreCourseSearchedDataWithExtraInfoAndOptions) |
(CoreEnrolledCourseDataWithOptions & {
categoryname?: string; // Category name,
});

View File

@ -0,0 +1,101 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreSiteWSPreSets } from '@classes/site';
import { makeSingleton } from '@singletons';
const ROOT_CACHE_KEY = 'AddonBlockStarredCourses:';
/**
* Service that provides some features regarding starred courses.
*/
@Injectable( { providedIn: 'root' })
export class AddonBlockStarredCoursesProvider {
/**
* Get cache key for get starred courrses value WS call.
*
* @return Cache key.
*/
protected getStarredCoursesCacheKey(): string {
return ROOT_CACHE_KEY + ':starredcourses';
}
/**
* Get starred courrses.
*
* @param siteId Site ID. If not defined, use current site.
* @return Promise resolved when the info is retrieved.
*/
async getStarredCourses(siteId?: string): Promise<AddonBlockStarredCourse[]> {
const site = await CoreSites.getSite(siteId);
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getStarredCoursesCacheKey(),
};
return await site.read<AddonBlockStarredCourse[]>('block_starredcourses_get_starred_courses', undefined, preSets);
}
/**
* Invalidates get starred courrses WS call.
*
* @param siteId Site ID to invalidate. If not defined, use current site.
* @return Promise resolved when the data is invalidated.
*/
async invalidateStarredCourses(siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
await site.invalidateWsCacheForKey(this.getStarredCoursesCacheKey());
}
}
export const AddonBlockStarredCourses = makeSingleton(AddonBlockStarredCoursesProvider);
/**
* Params of block_starredcourses_get_starred_courses WS.
*/
export type AddonBlockStarredCoursesGetStarredCoursesWSParams = {
limit?: number; // Limit.
offset?: number; // Offset.
};
/**
* Data returned by block_starredcourses_get_starred_courses WS.
*/
export type AddonBlockStarredCourse = {
id: number; // Id.
fullname: string; // Fullname.
shortname: string; // Shortname.
idnumber: string; // Idnumber.
summary: string; // Summary.
summaryformat: number; // Summary format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
startdate: number; // Startdate.
enddate: number; // Enddate.
visible: boolean; // Visible.
showactivitydates: boolean; // Showactivitydates.
showcompletionconditions: boolean; // Showcompletionconditions.
fullnamedisplay: string; // Fullnamedisplay.
viewurl: string; // Viewurl.
courseimage: string; // Courseimage.
progress?: number; // Progress.
hasprogress: boolean; // Hasprogress.
isfavourite: boolean; // Isfavourite.
hidden: boolean; // Hidden.
timeaccess?: number; // Timeaccess.
showshortname: boolean; // Showshortname.
coursecategory: string; // Coursecategory.
};

View File

@ -1,11 +1,9 @@
:host .core-block-content ::ng-deep { :host .core-block-content ::ng-deep {
.tag_cloud { .tag_cloud {
font-size: 80%;
text-align: center; text-align: center;
ul.inline-list { ul.inline-list {
list-style: none; list-style: none;
margin-left: 0; margin: 0;
margin-right: 0;
-webkit-padding-start: 0; -webkit-padding-start: 0;
li { li {
@ -13,8 +11,8 @@
display: inline-block; display: inline-block;
a { a {
background: var(--ion-color-primary); background: var(--primary);
color: var(--ion-color-primary-contrast); color: var(--primary-contrast);
padding: 3px 8px; padding: 3px 8px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
display: inline-block; display: inline-block;
@ -26,7 +24,7 @@
contain: content; contain: content;
vertical-align: baseline; vertical-align: baseline;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--small-radius);
} }
.s20 { .s20 {
font-size: 2.7em; font-size: 2.7em;

View File

@ -15,11 +15,10 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module'; import { CoreSharedModule } from '@/core/shared.module';
import { CoreCoursesComponentsModule } from '@features/courses/components/components.module';
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
import { AddonBlockTimelineComponent } from './timeline/timeline'; import { AddonBlockTimelineComponent } from './timeline/timeline';
import { AddonBlockTimelineEventsComponent } from './events/events'; import { AddonBlockTimelineEventsComponent } from './events/events';
import { CoreSearchComponentsModule } from '@features/search/components/components.module';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -28,8 +27,7 @@ import { AddonBlockTimelineEventsComponent } from './events/events';
], ],
imports: [ imports: [
CoreSharedModule, CoreSharedModule,
CoreCoursesComponentsModule, CoreSearchComponentsModule,
CoreCourseComponentsModule,
], ],
exports: [ exports: [
AddonBlockTimelineComponent, AddonBlockTimelineComponent,

View File

@ -1,62 +1,83 @@
<ion-item *ngIf="course">
<ion-label class="ion-text-wrap">
<h3>
<span class="sr-only">{{ 'core.courses.aria:coursename' | translate }}</span>
<core-format-text [text]="course.displayname || course.fullname" contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
</h3>
</ion-label>
</ion-item>
<ion-item-group *ngFor="let dayEvents of filteredEvents"> <ion-item-group *ngFor="let dayEvents of filteredEvents">
<ion-item-divider [color]="dayEvents.color"> <ion-item>
<ion-label><h3>{{ dayEvents.dayTimestamp * 1000 | coreFormatDate:"strftimedayshort" }}</h3></ion-label> <ion-label>
</ion-item-divider> <h4 [class.core-bold]="!course">{{ dayEvents.dayTimestamp * 1000 | coreFormatDate:"strftimedaydate" }}</h4>
</ion-label>
</ion-item>
<ng-container *ngFor="let event of dayEvents.events"> <ng-container *ngFor="let event of dayEvents.events">
<ion-item class="ion-text-wrap core-course-module-handler item-media" detail="false" (click)="action($event, event.url)" <ion-item class="addon-block-timeline-activity" detail="false" (click)="action($event, event.url)" [attr.aria-label]="event.name"
[attr.aria-label]="event.name" button> button lines="full">
<img slot="start" [src]="event.iconUrl" alt="" role="presentation" *ngIf="event.iconUrl" class="core-module-icon">
<ion-label> <ion-label>
<!-- Add the icon title so accessibility tools read it. --> <ion-row class="ion-justify-content-between ion-align-items-center ion-no-padding">
<span class="sr-only" *ngIf="event.iconTitle">{{ event.iconTitle }}</span> <ion-col class="addon-block-timeline-activity-main ion-no-padding">
<p class="item-heading"> <ion-row class="ion-justify-content-between ion-align-items-center ion-nowrap ion-no-padding">
<core-format-text [text]="event.name" contextLevel="module" [contextInstanceId]="event.id" <ion-col class="addon-block-timeline-activity-time ion-no-padding">
[courseId]="event.course && event.course.id"> <small>{{event.timesort * 1000 | coreFormatDate:"strftimetime24" }}</small>
</core-format-text> <core-mod-icon *ngIf="event.iconUrl" [modicon]="event.iconUrl" [componentId]="event.instance"
</p> [modname]="event.modulename">
<p *ngIf="showCourse && event.course"> </core-mod-icon>
<core-format-text [text]="event.course.fullnamedisplay" contextLevel="course" </ion-col>
[contextInstanceId]="event.course.id"> <ion-col class="addon-block-timeline-activity-name ion-no-padding">
</core-format-text> <p class="item-heading addon-block-timeline-activity-name-with-status">
</p> <span>
<core-format-text [text]="event.activityname || event.name" contextLevel="module"
<ion-button fill="clear" class="ion-hide-md-up ion-text-wrap" (click)="action($event, event.action.url)" [contextInstanceId]="event.id" [courseId]="event.course && event.course.id">
[title]="event.action.name" [disabled]="!event.action.actionable" *ngIf="event.action"> </core-format-text>
{{event.action.name}} </span>
<ion-badge slot="end" class="ion-margin-start" *ngIf="event.action.showitemcount">{{event.action.itemcount}} <ion-badge *ngIf="event.overdue" color="danger">{{ 'addon.block_timeline.overdue' | translate }}
</ion-badge> </ion-badge>
</ion-button> </p>
<p *ngIf="(showCourse && event.course) || event.activitystr"
class="addon-block-timeline-activity-course-activity">
<span *ngIf="showCourse && event.course">
<core-format-text [text]="event.course.fullnamedisplay" contextLevel="course"
[contextInstanceId]="event.course.id">
</core-format-text>
</span>
<span *ngIf="event.activitystr">
<core-format-text *ngIf="event.activitystr" [text]="event.activitystr" contextLevel="module"
[contextInstanceId]="event.id">
</core-format-text>
</span>
</p>
</ion-col>
</ion-row>
</ion-col>
<ion-col class="addon-block-timeline-activity-action ion-no-padding" *ngIf="event.action?.actionable">
<ion-button fill="outline" (click)="action($event, event.action.url)" [title]="event.action.name" class="chip">
{{event.action.name}}
<ion-badge slot="end" class="ion-margin-start" *ngIf="event.action.showitemcount">
{{event.action.itemcount}}
</ion-badge>
</ion-button>
</ion-col>
</ion-row>
</ion-label> </ion-label>
<div slot="end" class="events-info">
<div>
<ion-badge color="light">{{event.timesort * 1000 | coreFormatDate:"strftimetime24" }}</ion-badge>
</div>
<ion-button
class="ion-hide-md-down"
fill="clear"
(click)="action($event, event.action.url)"
[title]="event.action.name"
[disabled]="!event.action.actionable" *ngIf="event.action"
>
{{event.action.name}}
<ion-badge slot="end" class="ion-margin-start" *ngIf="event.action.showitemcount">
{{event.action.itemcount}}
</ion-badge>
</ion-button>
</div>
</ion-item> </ion-item>
</ng-container> </ng-container>
</ion-item-group> </ion-item-group>
<div class="ion-padding ion-text-center" *ngIf="canLoadMore && !empty"> <div class="ion-padding ion-text-center" *ngIf="canLoadMore && !empty">
<!-- Button and spinner to show more attempts. --> <!-- Button and spinner to show more attempts. -->
<ion-button expand="block" (click)="loadMoreEvents()" color="light" *ngIf="!loadingMore"> <ion-button expand="block" (click)="loadMoreEvents()" fill="outline" *ngIf="!loadingMore">
{{ 'core.loadmore' | translate }} {{ 'core.loadmore' | translate }}
</ion-button> </ion-button>
<ion-spinner *ngIf="loadingMore" [attr.aria-label]="'core.loading' | translate"></ion-spinner> <ion-spinner *ngIf="loadingMore" [attr.aria-label]="'core.loading' | translate"></ion-spinner>
</div> </div>
<core-empty-box *ngIf="empty" image="assets/img/icons/activities.svg" [message]="'addon.block_timeline.noevents' | translate" <ion-item *ngIf="empty && course">
inline="true"> <ion-label class="ion-text-wrap">
<p>{{'addon.block_timeline.noevents' | translate}}</p>
</ion-label>
</ion-item>
<core-empty-box *ngIf="empty && !course" image="assets/img/icons/activities.svg" [message]="'addon.block_timeline.noevents' | translate">
</core-empty-box> </core-empty-box>

View File

@ -1,6 +1,81 @@
.events-info { @import "~theme/globals";
display: flex;
flex-direction: column; h3 {
text-align: end; font-weight: bold;
padding: 10px 0; font-size: 18px;
}
h4 {
font-size: 15px;
}
h4.core-bold {
font-weight: bold;
}
.addon-block-timeline-activity {
ion-badge {
@include margin-horizontal(0.25rem, 0.5rem);
}
small {
@include margin-horizontal(null, 0.5rem);
}
core-mod-icon {
padding: 8px;
--margin-end: 0.5rem;
--margin-vertical: 0;
}
}
.addon-block-timeline-activity-time {
flex-grow: 0;
}
.addon-block-timeline-activity-action {
display: flex;
justify-content: flex-end;
}
.addon-block-timeline-activity-name-with-status {
display: flex;
flex-wrap: wrap;
span {
overflow: hidden;
text-overflow: ellipsis;
}
}
.addon-block-timeline-activity-course-activity {
display: flex;
flex-wrap: wrap;
span {
overflow: hidden;
text-overflow: ellipsis;
}
span::after {
content: "·";
display: inline;
padding-left: .3rem;
padding-right: .3rem;
}
span:last-child::after {
display: none;
}
}
.addon-block-timeline-activity-main,
.addon-block-timeline-activity-name {
flex-grow: 1;
p {
overflow: hidden;
text-overflow: ellipsis;
}
}
.addon-block-timeline-activity-name {
flex-grow: 1;
overflow: hidden;
} }

View File

@ -17,11 +17,11 @@ import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text'; import { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time'; import { CoreTimeUtils } from '@services/utils/time';
import { CoreUtils } from '@services/utils/utils';
import { CoreCourse } from '@features/course/services/course'; import { CoreCourse } from '@features/course/services/course';
import moment from 'moment';
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
import { AddonCalendarEvent } from '@addons/calendar/services/calendar'; import { AddonCalendarEvent } from '@addons/calendar/services/calendar';
import { CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper';
import { AddonBlockTimeline } from '../../services/timeline';
/** /**
* Directive to render a list of events in course overview. * Directive to render a list of events in course overview.
@ -34,34 +34,33 @@ import { AddonCalendarEvent } from '@addons/calendar/services/calendar';
export class AddonBlockTimelineEventsComponent implements OnChanges { export class AddonBlockTimelineEventsComponent implements OnChanges {
@Input() events: AddonBlockTimelineEvent[] = []; // The events to render. @Input() events: AddonBlockTimelineEvent[] = []; // The events to render.
@Input() showCourse?: boolean | string; // Whether to show the course name. @Input() course?: CoreEnrolledCourseDataWithOptions; // Whether to show the course name.
@Input() from = 0; // Number of days from today to offset the events. @Input() from = 0; // Number of days from today to offset the events.
@Input() to?: number; // Number of days from today to limit the events to. If not defined, no limit. @Input() to?: number; // Number of days from today to limit the events to. If not defined, no limit.
@Input() canLoadMore?: boolean; // Whether more events can be loaded. @Input() overdue = false; // If filtering overdue events or not.
@Output() loadMore: EventEmitter<void>; // Notify that more events should be loaded. @Input() canLoadMore = false; // Whether more events can be loaded.
@Output() loadMore = new EventEmitter(); // Notify that more events should be loaded.
showCourse = false; // Whether to show the course name.
empty = true; empty = true;
loadingMore = false; loadingMore = false;
filteredEvents: AddonBlockTimelineEventFilteredEvent[] = []; filteredEvents: AddonBlockTimelineEventFilteredEvent[] = [];
constructor() {
this.loadMore = new EventEmitter();
}
/** /**
* Detect changes on input properties. * @inheritdoc
*/ */
ngOnChanges(changes: {[name: string]: SimpleChange}): void { async ngOnChanges(changes: {[name: string]: SimpleChange}): Promise<void> {
this.showCourse = CoreUtils.isTrueOrOne(this.showCourse); this.showCourse = !this.course;
if (changes.events || changes.from || changes.to) { if (changes.events || changes.from || changes.to) {
if (this.events && this.events.length > 0) { if (this.events) {
const filteredEvents = this.filterEventsByTime(this.from, this.to); const filteredEvents = await this.filterEventsByTime();
this.empty = !filteredEvents || filteredEvents.length <= 0; this.empty = !filteredEvents || filteredEvents.length <= 0;
const eventsByDay: Record<number, AddonCalendarEvent[]> = {}; const eventsByDay: Record<number, AddonBlockTimelineEvent[]> = {};
filteredEvents.forEach((event) => { filteredEvents.forEach((event) => {
const dayTimestamp = CoreTimeUtils.getMidnightForTimestamp(event.timesort); const dayTimestamp = CoreTimeUtils.getMidnightForTimestamp(event.timesort);
if (eventsByDay[dayTimestamp]) { if (eventsByDay[dayTimestamp]) {
eventsByDay[dayTimestamp].push(event); eventsByDay[dayTimestamp].push(event);
} else { } else {
@ -69,16 +68,15 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
} }
}); });
const todaysMidnight = CoreTimeUtils.getMidnightForTimestamp(); this.filteredEvents = Object.keys(eventsByDay).map((key) => {
this.filteredEvents = [];
Object.keys(eventsByDay).forEach((key) => {
const dayTimestamp = parseInt(key); const dayTimestamp = parseInt(key);
this.filteredEvents.push({
color: dayTimestamp < todaysMidnight ? 'danger' : 'light', return {
dayTimestamp, dayTimestamp,
events: eventsByDay[dayTimestamp], events: eventsByDay[dayTimestamp],
}); };
}); });
this.loadingMore = false;
} else { } else {
this.empty = true; this.empty = true;
} }
@ -88,26 +86,41 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
/** /**
* Filter the events by time. * Filter the events by time.
* *
* @param start Number of days to start getting events from today. E.g. -1 will get events from yesterday.
* @param end Number of days after the start.
* @return Filtered events. * @return Filtered events.
*/ */
protected filterEventsByTime(start: number, end?: number): AddonBlockTimelineEvent[] { protected async filterEventsByTime(): Promise<AddonBlockTimelineEvent[]> {
start = moment().add(start, 'days').startOf('day').unix(); const start = AddonBlockTimeline.getDayStart(this.from);
end = typeof end != 'undefined' ? moment().add(end, 'days').startOf('day').unix() : end; const end = this.to !== undefined
? AddonBlockTimeline.getDayStart(this.to)
: undefined;
return this.events.filter((event) => { const now = CoreTimeUtils.timestamp();
if (end) { const midnight = AddonBlockTimeline.getDayStart();
return start <= event.timesort && event.timesort < end;
return await Promise.all(this.events.filter((event) => {
if (start > event.timesort || (end && event.timesort >= end)) {
return false;
} }
return start <= event.timesort; // Already calculated on 4.0 onwards but this will be live.
}).map((event) => { event.overdue = event.timesort < now;
event.iconUrl = CoreCourse.getModuleIconSrc(event.icon.component);
event.iconTitle = event.modulename && CoreCourse.translateModuleName(event.modulename); if (event.eventtype === 'open' || event.eventtype === 'opensubmission') {
const dayTimestamp = CoreTimeUtils.getMidnightForTimestamp(event.timesort);
return dayTimestamp > midnight;
}
// When filtering by overdue, we fetch all events due today, in case any have elapsed already and are overdue.
// This means if filtering by overdue, some events fetched might not be required (eg if due later today).
return (!this.overdue || event.overdue);
}).map(async (event) => {
event.iconUrl = await CoreCourse.getModuleIconSrc(event.icon.component);
event.modulename = event.modulename || event.icon.component;
event.iconTitle = CoreCourse.translateModuleName(event.modulename);
return event; return event;
}); }));
} }
/** /**
@ -121,12 +134,12 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
/** /**
* Action clicked. * Action clicked.
* *
* @param e Click event. * @param event Click event.
* @param url Url of the action. * @param url Url of the action.
*/ */
async action(e: Event, url: string): Promise<void> { async action(event: Event, url: string): Promise<void> {
e.preventDefault(); event.preventDefault();
e.stopPropagation(); event.stopPropagation();
// Fix URL format. // Fix URL format.
url = CoreTextUtils.decodeHTMLEntities(url); url = CoreTextUtils.decodeHTMLEntities(url);
@ -136,7 +149,7 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
try { try {
const treated = await CoreContentLinksHelper.handleLink(url); const treated = await CoreContentLinksHelper.handleLink(url);
if (!treated) { if (!treated) {
return CoreSites.getCurrentSite()?.openInBrowserWithAutoLoginIfSameSite(url); return CoreSites.getRequiredCurrentSite().openInBrowserWithAutoLoginIfSameSite(url);
} }
} finally { } finally {
modal.dismiss(); modal.dismiss();
@ -145,7 +158,8 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
} }
type AddonBlockTimelineEvent = AddonCalendarEvent & { type AddonBlockTimelineEvent = Omit<AddonCalendarEvent, 'eventtype'> & {
eventtype: string;
iconUrl?: string; iconUrl?: string;
iconTitle?: string; iconTitle?: string;
}; };
@ -153,5 +167,4 @@ type AddonBlockTimelineEvent = AddonCalendarEvent & {
type AddonBlockTimelineEventFilteredEvent = { type AddonBlockTimelineEventFilteredEvent = {
events: AddonBlockTimelineEvent[]; events: AddonBlockTimelineEvent[];
dayTimestamp: number; dayTimestamp: number;
color: string;
}; };

View File

@ -1,57 +1,73 @@
<ion-item-divider sticky="true"> <ion-item-divider sticky="true">
<ion-label><h2>{{ 'addon.block_timeline.pluginname' | translate }}</h2></ion-label> <ion-label>
<core-context-menu slot="end"> <h2>{{ 'addon.block_timeline.pluginname' | translate }}</h2>
<core-context-menu-item *ngIf="loaded" [priority]="900" [content]="'addon.block_timeline.sortbydates' | translate" </ion-label>
(action)="switchSort('sortbydates')" [iconAction]="sort == 'sortbydates' ? 'far-dot-circle' : 'far-circle'">
</core-context-menu-item>
<core-context-menu-item *ngIf="loaded" [priority]="800" [content]="'addon.block_timeline.sortbycourses' | translate"
(action)="switchSort('sortbycourses')" [iconAction]="sort == 'sortbycourses' ? 'far-dot-circle' : 'far-circle'">
</core-context-menu-item>
</core-context-menu>
</ion-item-divider> </ion-item-divider>
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="margin"> <core-loading [hideUntil]="loaded">
<div class="safe-padding-horizontal"> <ion-row class="ion-hide-md-up addon-block-timeline-filter" *ngIf="searchEnabled">
<core-combobox [selection]="filter" (onChange)="switchFilter($event)"> <ion-col>
<ion-select-option class="ion-text-wrap" value="all"> <!-- Filter courses. -->
{{ 'core.all' | translate }} <core-search-box (onSubmit)="searchTextChanged($event)" (onClear)="searchTextChanged()"
</ion-select-option> [placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" lengthCheck="2"
<ion-select-option class="ion-text-wrap" value="overdue"> searchArea="AddonBlockTimeline"></core-search-box>
{{ 'addon.block_timeline.overdue' | translate }} </ion-col>
</ion-select-option> </ion-row>
<ion-select-option class="ion-text-wrap" disabled value="disabled"> <ion-row class="ion-justify-content-between ion-align-items-center addon-block-timeline-filter">
{{ 'addon.block_timeline.duedate' | translate }} <ion-col size="auto">
</ion-select-option> <core-combobox [selection]="filter" (onChange)="switchFilter($event)">
<ion-select-option class="ion-text-wrap" value="next7days"> <ion-select-option class="ion-text-wrap" value="all">
{{ 'addon.block_timeline.next7days' | translate }} {{ 'core.all' | translate }}
</ion-select-option> </ion-select-option>
<ion-select-option class="ion-text-wrap" value="next30days"> <ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="overdue">
{{ 'addon.block_timeline.next30days' | translate }} {{ 'addon.block_timeline.overdue' | translate }}
</ion-select-option> </ion-select-option>
<ion-select-option class="ion-text-wrap" value="next3months"> <ion-select-option class="ion-text-wrap core-select-option-title" disabled value="disabled">
{{ 'addon.block_timeline.next3months' | translate }} {{ 'addon.block_timeline.duedate' | translate }}
</ion-select-option> </ion-select-option>
<ion-select-option class="ion-text-wrap" value="next6months"> <ion-select-option class="ion-text-wrap" value="next7days">
{{ 'addon.block_timeline.next6months' | translate }} {{ 'addon.block_timeline.next7days' | translate }}
</ion-select-option> </ion-select-option>
</core-combobox> <ion-select-option class="ion-text-wrap" value="next30days">
</div> {{ 'addon.block_timeline.next30days' | translate }}
<core-loading [hideUntil]="timeline.loaded" [hidden]="sort != 'sortbydates'" [fullscreen]="false" class="margin"> </ion-select-option>
<addon-block-timeline-events [events]="timeline.events" showCourse="true" [canLoadMore]="timeline.canLoadMore" <ion-select-option class="ion-text-wrap" value="next3months">
(loadMore)="loadMoreTimeline()" [from]="dataFrom" [to]="dataTo"></addon-block-timeline-events> {{ 'addon.block_timeline.next3months' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="next6months">
{{ 'addon.block_timeline.next6months' | translate }}
</ion-select-option>
</core-combobox>
</ion-col>
<ion-col class="ion-hide-md-down" *ngIf="searchEnabled">
<!-- Filter courses. -->
<core-search-box (onSubmit)="searchTextChanged($event)" (onClear)="searchTextChanged()"
[placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" lengthCheck="2"
searchArea="AddonBlockTimeline"></core-search-box>
</ion-col>
<ion-col size="auto">
<core-combobox [label]="'core.sortby' | translate" [selection]="sort" (onChange)="switchSort($event)"
icon="fas-sort-amount-down-alt">
<ion-select-option class="ion-text-wrap" value="sortbydates">
{{'addon.block_timeline.sortbydates' | translate}}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="sortbycourses">
{{'addon.block_timeline.sortbycourses' | translate}}
</ion-select-option>
</core-combobox>
</ion-col>
</ion-row>
<core-loading [hideUntil]="timeline.loaded" [hidden]="sort != 'sortbydates'">
<addon-block-timeline-events [events]="timeline.events" [canLoadMore]="timeline.canLoadMore" (loadMore)="loadMore()"
[from]="dataFrom" [to]="dataTo" [overdue]="overdue"></addon-block-timeline-events>
</core-loading> </core-loading>
<core-loading [hideUntil]="timelineCourses.loaded" [hidden]="sort != 'sortbycourses'" <core-loading [hideUntil]="timelineCourses.loaded" [hidden]="sort != 'sortbycourses'">
[fullscreen]="false" class="safe-area-page margin"> <ng-container *ngFor="let course of timelineCourses.courses">
<ion-grid class="ion-no-padding"> <addon-block-timeline-events [events]="course.events" [canLoadMore]="course.canLoadMore" (loadMore)="loadMore(course)"
<ion-row class="ion-no-padding"> [course]="course" [from]="dataFrom" [to]="dataTo" [overdue]="overdue"></addon-block-timeline-events>
<ion-col *ngFor="let course of timelineCourses.courses" class="ion-no-padding" size="12" size-md="6"> </ng-container>
<core-courses-course-progress [course]="course"> <core-empty-box *ngIf="timelineCourses.courses.length == 0" image="assets/img/icons/courses.svg"
<addon-block-timeline-events [events]="course.events" [canLoadMore]="course.canLoadMore" [message]="'addon.block_timeline.noevents' | translate"></core-empty-box>
(loadMore)="loadMoreCourse(course)" [from]="dataFrom" [to]="dataTo"></addon-block-timeline-events>
</core-courses-course-progress>
</ion-col>
</ion-row>
</ion-grid>
<core-empty-box *ngIf="timelineCourses.courses.length == 0" image="assets/img/icons/courses.svg" inline="true"
[message]="'addon.block_timeline.nocoursesinprogress' | translate"></core-empty-box>
</core-loading> </core-loading>
</core-loading> </core-loading>

View File

@ -0,0 +1,38 @@
:host {
ion-row.addon-block-timeline-filter {
margin: 8px;
padding: 0;
ion-col {
padding: 0;
margin-right: 2px;
margin-left: 2px;
}
ion-button,
core-combobox ::ng-deep ion-button {
--border-width: 0;
--a11y-min-target-size: 40px;
margin: 0;
.select-icon {
display: none;
}
ion-icon {
font-size: 20px;
}
}
core-combobox ::ng-deep ion-select {
margin: 0;
--a11y-min-target-size: 40px;
}
core-search-box {
padding: 0;
margin: 0;
--height: 40px;
}
}
}

View File

@ -13,7 +13,6 @@
// limitations under the License. // limitations under the License.
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component';
import { AddonBlockTimeline } from '../../services/timeline'; import { AddonBlockTimeline } from '../../services/timeline';
@ -24,6 +23,7 @@ import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/
import { CoreSite } from '@classes/site'; import { CoreSite } from '@classes/site';
import { CoreCourses } from '@features/courses/services/courses'; import { CoreCourses } from '@features/courses/services/courses';
import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate';
import { CoreNavigator } from '@services/navigator';
/** /**
* Component to render a timeline block. * Component to render a timeline block.
@ -31,12 +31,13 @@ import { CoreCourseOptionsDelegate } from '@features/course/services/course-opti
@Component({ @Component({
selector: 'addon-block-timeline', selector: 'addon-block-timeline',
templateUrl: 'addon-block-timeline.html', templateUrl: 'addon-block-timeline.html',
styleUrls: ['timeline.scss'],
}) })
export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implements OnInit { export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implements OnInit {
sort = 'sortbydates'; sort = 'sortbydates';
filter = 'next30days'; filter = 'next30days';
currentSite?: CoreSite; currentSite!: CoreSite;
timeline: { timeline: {
events: AddonCalendarEvent[]; events: AddonCalendarEvent[];
loaded: boolean; loaded: boolean;
@ -57,24 +58,40 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
dataFrom?: number; dataFrom?: number;
dataTo?: number; dataTo?: number;
overdue = false;
protected courseIds: number[] = []; searchEnabled = false;
searchText = '';
protected courseIdsToInvalidate: number[] = [];
protected fetchContentDefaultError = 'Error getting timeline data.'; protected fetchContentDefaultError = 'Error getting timeline data.';
protected gradePeriodAfter = 0;
protected gradePeriodBefore = 0;
constructor() { constructor() {
super('AddonBlockTimelineComponent'); super('AddonBlockTimelineComponent');
} }
/** /**
* Component being initialized. * @inheritdoc
*/ */
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
this.currentSite = CoreSites.getCurrentSite(); try {
this.currentSite = CoreSites.getRequiredCurrentSite();
} catch (error) {
CoreDomUtils.showErrorModal(error);
this.filter = await this.currentSite!.getLocalSiteConfig('AddonBlockTimelineFilter', this.filter); CoreNavigator.back();
return;
}
this.filter = await this.currentSite.getLocalSiteConfig('AddonBlockTimelineFilter', this.filter);
this.switchFilter(this.filter); this.switchFilter(this.filter);
this.sort = await this.currentSite!.getLocalSiteConfig('AddonBlockTimelineSort', this.sort); this.sort = await this.currentSite.getLocalSiteConfig('AddonBlockTimelineSort', this.sort);
this.searchEnabled = this.currentSite.isVersionGreaterEqualThan('4.0');
super.ngOnInit(); super.ngOnInit();
} }
@ -91,8 +108,8 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
promises.push(AddonBlockTimeline.invalidateActionEventsByCourses()); promises.push(AddonBlockTimeline.invalidateActionEventsByCourses());
promises.push(CoreCourses.invalidateUserCourses()); promises.push(CoreCourses.invalidateUserCourses());
promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions()); promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions());
if (this.courseIds.length > 0) { if (this.courseIdsToInvalidate.length > 0) {
promises.push(CoreCourses.invalidateCoursesByField('ids', this.courseIds.join(','))); promises.push(CoreCourses.invalidateCoursesByField('ids', this.courseIdsToInvalidate.join(',')));
} }
return CoreUtils.allPromises(promises); return CoreUtils.allPromises(promises);
@ -117,28 +134,22 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
} }
} }
/**
* Load more events.
*/
async loadMoreTimeline(): Promise<void> {
try {
await this.fetchMyOverviewTimeline(this.timeline.canLoadMore);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError);
}
}
/** /**
* Load more events. * Load more events.
* *
* @param course Course. * @param course Course. If defined, it will update the course events, timeline otherwise.
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
async loadMoreCourse(course: AddonBlockTimelineCourse): Promise<void> { async loadMore(course?: AddonBlockTimelineCourse): Promise<void> {
try { try {
const courseEvents = await AddonBlockTimeline.getActionEventsByCourse(course.id, course.canLoadMore); if (course) {
course.events = course.events?.concat(courseEvents.events); const courseEvents =
course.canLoadMore = courseEvents.canLoadMore; await AddonBlockTimeline.getActionEventsByCourse(course.id, course.canLoadMore, this.searchText);
course.events = course.events?.concat(courseEvents.events);
course.canLoadMore = courseEvents.canLoadMore;
} else {
await this.fetchMyOverviewTimeline(this.timeline.canLoadMore);
}
} catch (error) { } catch (error) {
CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError); CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError);
} }
@ -151,9 +162,9 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
protected async fetchMyOverviewTimeline(afterEventId?: number): Promise<void> { protected async fetchMyOverviewTimeline(afterEventId?: number): Promise<void> {
const events = await AddonBlockTimeline.getActionEventsByTimesort(afterEventId); const events = await AddonBlockTimeline.getActionEventsByTimesort(afterEventId, this.searchText);
this.timeline.events = events.events; this.timeline.events = afterEventId ? this.timeline.events.concat(events.events) : events.events;
this.timeline.canLoadMore = events.canLoadMore; this.timeline.canLoadMore = events.canLoadMore;
} }
@ -163,20 +174,36 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
protected async fetchMyOverviewTimelineByCourses(): Promise<void> { protected async fetchMyOverviewTimelineByCourses(): Promise<void> {
const courses = await CoreCoursesHelper.getUserCoursesWithOptions(); try {
const today = CoreTimeUtils.timestamp(); this.gradePeriodAfter = parseInt(await this.currentSite.getConfig('coursegraceperiodafter'), 10);
this.gradePeriodBefore = parseInt(await this.currentSite.getConfig('coursegraceperiodbefore'), 10);
} catch {
this.gradePeriodAfter = 0;
this.gradePeriodBefore = 0;
}
this.timelineCourses.courses = courses.filter((course) => // Do not filter courses by date because they can contain activities due.
(course.startdate || 0) <= today && (!course.enddate || course.enddate >= today)); this.timelineCourses.courses = await CoreCoursesHelper.getUserCoursesWithOptions();
this.courseIdsToInvalidate = this.timelineCourses.courses.map((course) => course.id);
// Filter only in progress courses.
this.timelineCourses.courses = this.timelineCourses.courses.filter((course) =>
!course.hidden &&
!CoreCoursesHelper.isPastCourse(course, this.gradePeriodAfter) &&
!CoreCoursesHelper.isFutureCourse(course, this.gradePeriodAfter, this.gradePeriodBefore));
if (this.timelineCourses.courses.length > 0) { if (this.timelineCourses.courses.length > 0) {
this.courseIds = this.timelineCourses.courses.map((course) => course.id); const courseEvents = await AddonBlockTimeline.getActionEventsByCourses(this.courseIdsToInvalidate, this.searchText);
const courseEvents = await AddonBlockTimeline.getActionEventsByCourses(this.courseIds); this.timelineCourses.courses = this.timelineCourses.courses.filter((course) => {
if (courseEvents[course.id].events.length == 0) {
return false;
}
this.timelineCourses.courses.forEach((course) => {
course.events = courseEvents[course.id].events; course.events = courseEvents[course.id].events;
course.canLoadMore = courseEvents[course.id].canLoadMore; course.canLoadMore = courseEvents[course.id].canLoadMore;
return true;
}); });
} }
} }
@ -188,12 +215,13 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
*/ */
switchFilter(filter: string): void { switchFilter(filter: string): void {
this.filter = filter; this.filter = filter;
this.currentSite?.setLocalSiteConfig('AddonBlockTimelineFilter', this.filter); this.currentSite.setLocalSiteConfig('AddonBlockTimelineFilter', this.filter);
this.overdue = this.filter === 'overdue';
switch (this.filter) { switch (this.filter) {
case 'overdue': case 'overdue':
this.dataFrom = -14; this.dataFrom = -14;
this.dataTo = 0; this.dataTo = 1;
break; break;
case 'next7days': case 'next7days':
this.dataFrom = 0; this.dataFrom = 0;
@ -226,7 +254,7 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
*/ */
switchSort(sort: string): void { switchSort(sort: string): void {
this.sort = sort; this.sort = sort;
this.currentSite?.setLocalSiteConfig('AddonBlockTimelineSort', this.sort); this.currentSite.setLocalSiteConfig('AddonBlockTimelineSort', this.sort);
if (!this.timeline.loaded && this.sort == 'sortbydates') { if (!this.timeline.loaded && this.sort == 'sortbydates') {
this.fetchContent(); this.fetchContent();
@ -235,9 +263,20 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
} }
} }
/**
* Search text changed.
*
* @param searchValue Search value
*/
searchTextChanged(searchValue = ''): void {
this.searchText = searchValue || '';
this.fetchContent();
}
} }
type AddonBlockTimelineCourse = CoreEnrolledCourseDataWithOptions & { export type AddonBlockTimelineCourse = CoreEnrolledCourseDataWithOptions & {
events?: AddonCalendarEvent[]; events?: AddonCalendarEvent[];
canLoadMore?: number; canLoadMore?: number;
}; };

View File

@ -5,9 +5,10 @@
"next6months": "Next 6 months", "next6months": "Next 6 months",
"next7days": "Next 7 days", "next7days": "Next 7 days",
"nocoursesinprogress": "No in-progress courses", "nocoursesinprogress": "No in-progress courses",
"noevents": "No upcoming activities due", "noevents": "No activities require action",
"overdue": "Overdue", "overdue": "Overdue",
"pluginname": "Timeline", "pluginname": "Timeline",
"searchevents": "Search by activity type or name",
"sortbycourses": "Sort by courses", "sortbycourses": "Sort by courses",
"sortbydates": "Sort by dates" "sortbydates": "Sort by dates"
} }

View File

@ -19,7 +19,7 @@ import { CoreCourses } from '@features/courses/services/courses';
import { AddonBlockTimelineComponent } from '@addons/block/timeline/components/timeline/timeline'; import { AddonBlockTimelineComponent } from '@addons/block/timeline/components/timeline/timeline';
import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler'; import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
import { AddonBlockTimeline } from './timeline'; import { CoreCoursesDashboard } from '@features/courses/services/dashboard';
/** /**
* Block handler. * Block handler.
@ -36,7 +36,7 @@ export class AddonBlockTimelineHandlerService extends CoreBlockBaseHandler {
* @return Whether or not the handler is enabled on a site level. * @return Whether or not the handler is enabled on a site level.
*/ */
async isEnabled(): Promise<boolean> { async isEnabled(): Promise<boolean> {
const enabled = await AddonBlockTimeline.isAvailable(); const enabled = !CoreCoursesDashboard.isDisabledInSite();
const currentSite = CoreSites.getCurrentSite(); const currentSite = CoreSites.getCurrentSite();
return enabled && ((currentSite && currentSite.isVersionGreaterEqualThan('3.6')) || return enabled && ((currentSite && currentSite.isVersionGreaterEqualThan('3.6')) ||

View File

@ -14,7 +14,6 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreCoursesDashboard } from '@features/courses/services/dashboard';
import { import {
AddonCalendarEvents, AddonCalendarEvents,
AddonCalendarEventsGroupedByCourse, AddonCalendarEventsGroupedByCourse,
@ -26,7 +25,6 @@ import {
import moment from 'moment'; import moment from 'moment';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
import { CoreSiteWSPreSets } from '@classes/site'; import { CoreSiteWSPreSets } from '@classes/site';
import { CoreError } from '@classes/errors/error';
// Cache key was maintained from block myoverview when blocks were splitted. // Cache key was maintained from block myoverview when blocks were splitted.
const ROOT_CACHE_KEY = 'myoverview:'; const ROOT_CACHE_KEY = 'myoverview:';
@ -45,17 +43,19 @@ export class AddonBlockTimelineProvider {
* *
* @param courseId Only events in this course. * @param courseId Only events in this course.
* @param afterEventId The last seen event id. * @param afterEventId The last seen event id.
* @param searchValue The value a user wishes to search against.
* @param siteId Site ID. If not defined, use current site. * @param siteId Site ID. If not defined, use current site.
* @return Promise resolved when the info is retrieved. * @return Promise resolved when the info is retrieved.
*/ */
async getActionEventsByCourse( async getActionEventsByCourse(
courseId: number, courseId: number,
afterEventId?: number, afterEventId?: number,
searchValue = '',
siteId?: string, siteId?: string,
): Promise<{ events: AddonCalendarEvent[]; canLoadMore?: number }> { ): Promise<{ events: AddonCalendarEvent[]; canLoadMore?: number }> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
const time = moment().subtract(14, 'days').unix(); // Check two weeks ago. const time = this.getDayStart(-14); // Check two weeks ago.
const data: AddonCalendarGetActionEventsByCourseWSParams = { const data: AddonCalendarGetActionEventsByCourseWSParams = {
timesortfrom: time, timesortfrom: time,
@ -70,17 +70,18 @@ export class AddonBlockTimelineProvider {
cacheKey: this.getActionEventsByCourseCacheKey(courseId), cacheKey: this.getActionEventsByCourseCacheKey(courseId),
}; };
if (searchValue != '') {
data.searchvalue = searchValue;
preSets.getFromCache = false;
}
const courseEvents = await site.read<AddonCalendarEvents>( const courseEvents = await site.read<AddonCalendarEvents>(
'core_calendar_get_action_events_by_course', 'core_calendar_get_action_events_by_course',
data, data,
preSets, preSets,
); );
if (courseEvents && courseEvents.events) { return this.treatCourseEvents(courseEvents, time);
return this.treatCourseEvents(courseEvents, time);
}
throw new CoreError('No events returned on core_calendar_get_action_events_by_course.');
} }
/** /**
@ -98,15 +99,17 @@ export class AddonBlockTimelineProvider {
* *
* @param courseIds Course IDs. * @param courseIds Course IDs.
* @param siteId Site ID. If not defined, use current site. * @param siteId Site ID. If not defined, use current site.
* @param searchValue The value a user wishes to search against.
* @return Promise resolved when the info is retrieved. * @return Promise resolved when the info is retrieved.
*/ */
async getActionEventsByCourses( async getActionEventsByCourses(
courseIds: number[], courseIds: number[],
searchValue = '',
siteId?: string, siteId?: string,
): Promise<{[courseId: string]: { events: AddonCalendarEvent[]; canLoadMore?: number } }> { ): Promise<{[courseId: string]: { events: AddonCalendarEvent[]; canLoadMore?: number } }> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
const time = moment().subtract(14, 'days').unix(); // Check two weeks ago. const time = this.getDayStart(-14); // Check two weeks ago.
const data: AddonCalendarGetActionEventsByCoursesWSParams = { const data: AddonCalendarGetActionEventsByCoursesWSParams = {
timesortfrom: time, timesortfrom: time,
@ -117,6 +120,11 @@ export class AddonBlockTimelineProvider {
cacheKey: this.getActionEventsByCoursesCacheKey(), cacheKey: this.getActionEventsByCoursesCacheKey(),
}; };
if (searchValue != '') {
data.searchvalue = searchValue;
preSets.getFromCache = false;
}
const events = await site.read<AddonCalendarEventsGroupedByCourse>( const events = await site.read<AddonCalendarEventsGroupedByCourse>(
'core_calendar_get_action_events_by_courses', 'core_calendar_get_action_events_by_courses',
data, data,
@ -145,16 +153,18 @@ export class AddonBlockTimelineProvider {
* Get calendar action events based on the timesort value. * Get calendar action events based on the timesort value.
* *
* @param afterEventId The last seen event id. * @param afterEventId The last seen event id.
* @param searchValue The value a user wishes to search against.
* @param siteId Site ID. If not defined, use current site. * @param siteId Site ID. If not defined, use current site.
* @return Promise resolved when the info is retrieved. * @return Promise resolved when the info is retrieved.
*/ */
async getActionEventsByTimesort( async getActionEventsByTimesort(
afterEventId?: number, afterEventId?: number,
searchValue = '',
siteId?: string, siteId?: string,
): Promise<{ events: AddonCalendarEvent[]; canLoadMore?: number }> { ): Promise<{ events: AddonCalendarEvent[]; canLoadMore?: number }> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
const timesortfrom = moment().subtract(14, 'days').unix(); // Check two weeks ago. const timesortfrom = this.getDayStart(-14); // Check two weeks ago.
const limitnum = AddonBlockTimelineProvider.EVENTS_LIMIT; const limitnum = AddonBlockTimelineProvider.EVENTS_LIMIT;
const data: AddonCalendarGetActionEventsByTimesortWSParams = { const data: AddonCalendarGetActionEventsByTimesortWSParams = {
@ -171,25 +181,27 @@ export class AddonBlockTimelineProvider {
uniqueCacheKey: true, uniqueCacheKey: true,
}; };
if (searchValue != '') {
data.searchvalue = searchValue;
preSets.getFromCache = false;
preSets.cacheKey += ':' + searchValue;
}
const result = await site.read<AddonCalendarEvents>( const result = await site.read<AddonCalendarEvents>(
'core_calendar_get_action_events_by_timesort', 'core_calendar_get_action_events_by_timesort',
data, data,
preSets, preSets,
); );
if (result && result.events) { const canLoadMore = result.events.length >= limitnum ? result.lastid : undefined;
const canLoadMore = result.events.length >= limitnum ? result.lastid : undefined;
// Filter events by time in case it uses cache. // Filter events by time in case it uses cache.
const events = result.events.filter((element) => element.timesort >= timesortfrom); const events = result.events.filter((element) => element.timesort >= timesortfrom);
return { return {
events, events,
canLoadMore, canLoadMore,
}; };
}
throw new CoreError('No events returned on core_calendar_get_action_events_by_timesort.');
} }
/** /**
@ -239,24 +251,6 @@ export class AddonBlockTimelineProvider {
await site.invalidateWsCacheForKeyStartingWith(this.getActionEventsByTimesortPrefixCacheKey()); await site.invalidateWsCacheForKeyStartingWith(this.getActionEventsByTimesortPrefixCacheKey());
} }
/**
* Returns whether or not My Overview is available for a certain site.
*
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with true if available, resolved with false or rejected otherwise.
*/
async isAvailable(siteId?: string): Promise<boolean> {
const site = await CoreSites.getSite(siteId);
// First check if dashboard is disabled.
if (CoreCoursesDashboard.isDisabledInSite(site)) {
return false;
}
return site.wsAvailable('core_calendar_get_action_events_by_courses') &&
site.wsAvailable('core_calendar_get_action_events_by_timesort');
}
/** /**
* Handles course events, filtering and treating if more can be loaded. * Handles course events, filtering and treating if more can be loaded.
* *
@ -281,6 +275,16 @@ export class AddonBlockTimelineProvider {
}; };
} }
/**
* Returns the timestamp at the start of the day with an optional offset.
*
* @param daysOffset Offset days to add or substract.
* @return timestamp.
*/
getDayStart(daysOffset = 0): number {
return moment().startOf('day').add(daysOffset, 'days').unix();
}
} }
export const AddonBlockTimeline = makeSingleton(AddonBlockTimelineProvider); export const AddonBlockTimeline = makeSingleton(AddonBlockTimelineProvider);

View File

@ -22,6 +22,7 @@ import { CoreCommentsComponentsModule } from '@features/comments/components/comp
import { CoreTagComponentsModule } from '@features/tag/components/components.module'; import { CoreTagComponentsModule } from '@features/tag/components/components.module';
import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module';
import { AddonBlogMainMenuHandlerService } from './services/handlers/mainmenu'; import { AddonBlogMainMenuHandlerService } from './services/handlers/mainmenu';
import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/components.module';
function buildRoutes(injector: Injector): Routes { function buildRoutes(injector: Injector): Routes {
return [ return [
@ -39,6 +40,7 @@ function buildRoutes(injector: Injector): Routes {
CoreSharedModule, CoreSharedModule,
CoreCommentsComponentsModule, CoreCommentsComponentsModule,
CoreTagComponentsModule, CoreTagComponentsModule,
CoreMainMenuComponentsModule,
], ],
exports: [RouterModule], exports: [RouterModule],
providers: [ providers: [

View File

@ -51,8 +51,7 @@ const routes: Routes = [
{ {
provide: APP_INITIALIZER, provide: APP_INITIALIZER,
multi: true, multi: true,
deps: [], useValue: () => {
useFactory: () => async () => {
CoreContentLinksDelegate.registerHandler(AddonBlogIndexLinkHandler.instance); CoreContentLinksDelegate.registerHandler(AddonBlogIndexLinkHandler.instance);
CoreMainMenuDelegate.registerHandler(AddonBlogMainMenuHandler.instance); CoreMainMenuDelegate.registerHandler(AddonBlogMainMenuHandler.instance);
CoreUserDelegate.registerHandler(AddonBlogUserHandler.instance); CoreUserDelegate.registerHandler(AddonBlogUserHandler.instance);

View File

@ -3,21 +3,24 @@
<ion-buttons slot="start"> <ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button> <ion-back-button [text]="'core.back' | translate"></ion-back-button>
</ion-buttons> </ion-buttons>
<h1>{{ title | translate }}</h1> <ion-title>
<ion-buttons slot="end"></ion-buttons> <h1>{{ title | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<core-user-menu-button></core-user-menu-button>
</ion-buttons>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content> <ion-content class="limited-width">
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refresh($event.target)"> <ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher> </ion-refresher>
<core-loading [hideUntil]="loaded"> <core-loading [hideUntil]="loaded">
<ion-item *ngIf="showMyEntriesToggle"> <ion-item *ngIf="showMyEntriesToggle">
<ion-label>{{ 'addon.blog.showonlyyourentries' | translate }}</ion-label> <ion-label>{{ 'addon.blog.showonlyyourentries' | translate }}</ion-label>
<ion-toggle [(ngModel)]="onlyMyEntries" (ionChange)="onlyMyEntriesToggleChanged(onlyMyEntries)"></ion-toggle> <ion-toggle [(ngModel)]="onlyMyEntries" (ionChange)="onlyMyEntriesToggleChanged(onlyMyEntries)"></ion-toggle>
</ion-item> </ion-item>
<core-empty-box *ngIf="entries && entries.length == 0" icon="far-newspaper" <core-empty-box *ngIf="entries && entries.length == 0" icon="far-newspaper" [message]="'addon.blog.noentriesyet' | translate">
[message]="'addon.blog.noentriesyet' | translate">
</core-empty-box> </core-empty-box>
<ng-container *ngFor="let entry of entries"> <ng-container *ngFor="let entry of entries">
<ion-card *ngIf="!onlyMyEntries || entry.userid == currentUserId"> <ion-card *ngIf="!onlyMyEntries || entry.userid == currentUserId">
@ -25,8 +28,7 @@
<core-user-avatar [user]="entry.user" slot="start" [courseId]="entry.courseid"></core-user-avatar> <core-user-avatar [user]="entry.user" slot="start" [courseId]="entry.courseid"></core-user-avatar>
<ion-label> <ion-label>
<p class="item-heading"> <p class="item-heading">
<core-format-text [text]="entry.subject" [contextLevel]="contextLevel" <core-format-text [text]="entry.subject" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId">
[contextInstanceId]="contextInstanceId">
</core-format-text> </core-format-text>
<ion-note class="ion-float-end ion-padding-start ion-text-end"> <ion-note class="ion-float-end ion-padding-start ion-text-end">
{{ 'addon.blog.' + entry.publishTranslated! | translate}} {{ 'addon.blog.' + entry.publishTranslated! | translate}}
@ -66,8 +68,9 @@
</ion-card-content> </ion-card-content>
<div class="ion-text-center ion-margin-bottom" *ngIf="entry.lastmodified > entry.created"> <div class="ion-text-center ion-margin-bottom" *ngIf="entry.lastmodified > entry.created">
<ion-note> <ion-note>
<ion-icon name="fas-clock" <ion-icon name="fas-clock" [attr.aria-label]="'core.lastmodified' | translate"></ion-icon> {{entry.lastmodified
[attr.aria-label]="'core.lastmodified' | translate"></ion-icon> {{entry.lastmodified | coreTimeAgo}} |
coreTimeAgo}}
</ion-note> </ion-note>
</div> </div>
</ion-card> </ion-card>

View File

@ -16,6 +16,7 @@ import { ContextLevel } from '@/core/constants';
import { AddonBlog, AddonBlogFilter, AddonBlogPost, AddonBlogProvider } from '@addons/blog/services/blog'; import { AddonBlog, AddonBlogFilter, AddonBlogPost, AddonBlogProvider } from '@addons/blog/services/blog';
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CoreComments } from '@features/comments/services/comments'; import { CoreComments } from '@features/comments/services/comments';
import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager';
import { CoreTag } from '@features/tag/services/tag'; import { CoreTag } from '@features/tag/services/tag';
import { CoreUser, CoreUserProfile } from '@features/user/services/user'; import { CoreUser, CoreUserProfile } from '@features/user/services/user';
import { IonRefresher } from '@ionic/angular'; import { IonRefresher } from '@ionic/angular';
@ -42,6 +43,7 @@ export class AddonBlogEntriesPage implements OnInit {
protected canLoadMoreEntries = false; protected canLoadMoreEntries = false;
protected canLoadMoreUserEntries = true; protected canLoadMoreUserEntries = true;
protected siteHomeId: number; protected siteHomeId: number;
protected fetchSuccess = false;
loaded = false; loaded = false;
canLoadMore = false; canLoadMore = false;
@ -118,9 +120,10 @@ export class AddonBlogEntriesPage implements OnInit {
this.commentsEnabled = !CoreComments.areCommentsDisabledInSite(); this.commentsEnabled = !CoreComments.areCommentsDisabledInSite();
this.tagsEnabled = CoreTag.areTagsAvailableInSite(); this.tagsEnabled = CoreTag.areTagsAvailableInSite();
await this.fetchEntries(); const deepLinkManager = new CoreMainMenuDeepLinkManager();
deepLinkManager.treatLink();
CoreUtils.ignoreErrors(AddonBlog.logView(this.filter)); await this.fetchEntries();
} }
/** /**
@ -170,15 +173,9 @@ export class AddonBlogEntriesPage implements OnInit {
entry.contextInstanceId = entry.userid; entry.contextInstanceId = entry.userid;
} }
entry.summary = CoreTextUtils.instance.replacePluginfileUrls(entry.summary, entry.summaryfiles || []); entry.summary = CoreTextUtils.replacePluginfileUrls(entry.summary, entry.summaryfiles || []);
return CoreUser.getProfile(entry.userid, entry.courseid, true).then((user) => { entry.user = await CoreUtils.ignoreErrors(CoreUser.getProfile(entry.userid, entry.courseid, true));
entry.user = user;
return;
}).catch(() => {
// Ignore errors.
});
}); });
if (refresh) { if (refresh) {
@ -201,6 +198,11 @@ export class AddonBlogEntriesPage implements OnInit {
} }
await Promise.all(promises); await Promise.all(promises);
if (!this.fetchSuccess) {
this.fetchSuccess = true;
CoreUtils.ignoreErrors(AddonBlog.logView(this.filter));
}
} catch (error) { } catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.blog.errorloadentries', true); CoreDomUtils.showErrorModalDefault(error, 'addon.blog.errorloadentries', true);
this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading.

View File

@ -40,11 +40,12 @@ export class AddonBlogProvider {
* *
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return Promise resolved with true if enabled, resolved with false or rejected otherwise. * @return Promise resolved with true if enabled, resolved with false or rejected otherwise.
* @since 3.6
*/ */
async isPluginEnabled(siteId?: string): Promise<boolean> { async isPluginEnabled(siteId?: string): Promise<boolean> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
return site.wsAvailable('core_blog_get_entries') &&site.canUseAdvancedFeature('enableblogs'); return site.wsAvailable('core_blog_get_entries') && site.canUseAdvancedFeature('enableblogs');
} }
/** /**

View File

@ -62,7 +62,7 @@ export class AddonBlogCourseOptionHandlerService implements CoreCourseOptionsHan
): Promise<boolean> { ): Promise<boolean> {
const enabled = await CoreCourseHelper.hasABlockNamed(courseId, 'blog_menu'); const enabled = await CoreCourseHelper.hasABlockNamed(courseId, 'blog_menu');
if (enabled && navOptions && typeof navOptions.blogs != 'undefined') { if (enabled && navOptions && navOptions.blogs !== undefined) {
return navOptions.blogs; return navOptions.blogs;
} }

View File

@ -26,7 +26,7 @@ export class AddonBlogMainMenuHandlerService implements CoreMainMenuHandler {
static readonly PAGE_NAME = 'blog'; static readonly PAGE_NAME = 'blog';
name = 'AddonBlog'; name = 'AddonBlog';
priority = 450; priority = 500;
/** /**
* @inheritdoc * @inheritdoc

View File

@ -13,8 +13,14 @@
// limitations under the License. // limitations under the License.
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CoreUserProfileHandler, CoreUserProfileHandlerData, CoreUserDelegateService } from '@features/user/services/user-delegate'; import {
CoreUserProfileHandler,
CoreUserProfileHandlerData,
CoreUserDelegateService,
CoreUserDelegateContext,
} from '@features/user/services/user-delegate';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
import { AddonBlog } from '../blog'; import { AddonBlog } from '../blog';
@ -24,8 +30,8 @@ import { AddonBlog } from '../blog';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AddonBlogUserHandlerService implements CoreUserProfileHandler { export class AddonBlogUserHandlerService implements CoreUserProfileHandler {
name = 'AddonBlog:blogs'; name = 'AddonBlog'; // This name doesn't match any disabled feature, they'll be checked in isEnabledForContext.
priority = 300; priority = 200;
type = CoreUserDelegateService.TYPE_NEW_PAGE; type = CoreUserDelegateService.TYPE_NEW_PAGE;
/** /**
@ -35,6 +41,27 @@ export class AddonBlogUserHandlerService implements CoreUserProfileHandler {
return AddonBlog.isPluginEnabled(); return AddonBlog.isPluginEnabled();
} }
/**
* @inheritdoc
*/
async isEnabledForContext(context: CoreUserDelegateContext): Promise<boolean> {
// Check if feature is disabled.
const currentSite = CoreSites.getCurrentSite();
if (!currentSite) {
return false;
}
if (context === CoreUserDelegateContext.USER_MENU) {
if (currentSite.isFeatureDisabled('CoreUserDelegate_AddonBlog:account')) {
return false;
}
} else if (currentSite.isFeatureDisabled('CoreUserDelegate_AddonBlog:blogs')) {
return false;
}
return true;
}
/** /**
* @inheritdoc * @inheritdoc
*/ */
@ -43,11 +70,11 @@ export class AddonBlogUserHandlerService implements CoreUserProfileHandler {
icon: 'far-newspaper', icon: 'far-newspaper',
title: 'addon.blog.blogentries', title: 'addon.blog.blogentries',
class: 'addon-blog-handler', class: 'addon-blog-handler',
action: (event, user, courseId): void => { action: (event, user, context, contextId): void => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
CoreNavigator.navigateToSitePath('/blog', { CoreNavigator.navigateToSitePath('/blog', {
params: { courseId, userId: user.id }, params: { courseId: contextId, userId: user.id },
}); });
}, },
}; };

View File

@ -1,28 +1,27 @@
@import "~theme/globals";
:host { :host {
--addon-calendar-blank-day-background-color: var(--gray-lighter); --addon-calendar-blank-day-background-color: var(--light);
.item.addon-calendar-event { .item.addon-calendar-event {
> ion-icon { > ion-icon {
color: white; color: white;
border-radius: 50%; border-radius: 50%;
padding: 6px; padding: 0.7rem;
--margin-vertical: 12px;
--margin-end: 12px;
margin-top: var(--margin-vertical);
margin-bottom: var(--margin-vertical);
@include margin-horizontal(null, var(--margin-end));
} }
&.addon-calendar-eventtype-category > ion-icon { @each $category, $value in $calendar-event-category-colors {
background-color: var(--addon-calendar-event-category-color); &.addon-calendar-eventtype-#{$category} > ion-icon {
} background-color: $value;
&.addon-calendar-eventtype-course > ion-icon { }
background-color: var(--addon-calendar-event-course-color);
}
&.addon-calendar-eventtype-group > ion-icon {
background-color: var(--addon-calendar-event-group-color);
}
&.addon-calendar-eventtype-user > ion-icon {
background-color: var(--addon-calendar-event-user-color);
}
&.addon-calendar-eventtype-site > ion-icon {
background-color: var(--addon-calendar-event-site-color);
} }
} }
} }

View File

@ -13,22 +13,11 @@
// limitations under the License. // limitations under the License.
import { Injector, NgModule } from '@angular/core'; import { Injector, NgModule } from '@angular/core';
import { Route, RouterModule, ROUTES, Routes } from '@angular/router'; import { RouterModule, ROUTES, Routes } from '@angular/router';
import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module';
import { AddonCalendarMainMenuHandlerService } from './services/handlers/mainmenu'; import { AddonCalendarMainMenuHandlerService } from './services/handlers/mainmenu';
export const AddonCalendarEditRoute: Route = {
path: 'edit/:eventId',
loadChildren: () =>
import('@/addons/calendar/pages/edit-event/edit-event.module').then(m => m.AddonCalendarEditEventPageModule),
};
export const AddonCalendarEventRoute: Route ={
path: 'event/:id',
loadChildren: () => import('@/addons/calendar/pages/event/event.module').then(m => m.AddonCalendarEventPageModule),
};
function buildRoutes(injector: Injector): Routes { function buildRoutes(injector: Injector): Routes {
return [ return [
{ {
@ -36,27 +25,27 @@ function buildRoutes(injector: Injector): Routes {
data: { data: {
mainMenuTabRoot: AddonCalendarMainMenuHandlerService.PAGE_NAME, mainMenuTabRoot: AddonCalendarMainMenuHandlerService.PAGE_NAME,
}, },
loadChildren: () => import('@/addons/calendar/pages/index/index.module').then(m => m.AddonCalendarIndexPageModule), loadChildren: () => import('@addons/calendar/pages/index/index.module').then(m => m.AddonCalendarIndexPageModule),
}, },
{ {
path: 'list', path: 'calendar-settings',
data: {
mainMenuTabRoot: AddonCalendarMainMenuHandlerService.PAGE_NAME,
},
loadChildren: () => import('@/addons/calendar/pages/list/list.module').then(m => m.AddonCalendarListPageModule),
},
{
path: 'settings',
loadChildren: () => loadChildren: () =>
import('@/addons/calendar/pages/settings/settings.module').then(m => m.AddonCalendarSettingsPageModule), import('@addons/calendar/pages/settings/settings.module').then(m => m.AddonCalendarSettingsPageModule),
}, },
{ {
path: 'day', path: 'day',
loadChildren: () => loadChildren: () =>
import('@/addons/calendar/pages/day/day.module').then(m => m.AddonCalendarDayPageModule), import('@addons/calendar/pages/day/day.module').then(m => m.AddonCalendarDayPageModule),
},
{
path: 'event/:id',
loadChildren: () => import('@addons/calendar/pages/event/event.module').then(m => m.AddonCalendarEventPageModule),
},
{
path: 'edit/:eventId',
loadChildren: () =>
import('@addons/calendar/pages/edit-event/edit-event.module').then(m => m.AddonCalendarEditEventPageModule),
}, },
AddonCalendarEventRoute,
AddonCalendarEditRoute,
...buildTabMainRoutes(injector, { ...buildTabMainRoutes(injector, {
redirectTo: 'index', redirectTo: 'index',
pathMatch: 'full', pathMatch: 'full',

View File

@ -63,8 +63,7 @@ const mainMenuChildrenRoutes: Routes = [
{ {
provide: APP_INITIALIZER, provide: APP_INITIALIZER,
multi: true, multi: true,
deps: [], useValue: async () => {
useFactory: () => async () => {
CoreContentLinksDelegate.registerHandler(AddonCalendarViewLinkHandler.instance); CoreContentLinksDelegate.registerHandler(AddonCalendarViewLinkHandler.instance);
CoreMainMenuDelegate.registerHandler(AddonCalendarMainMenuHandler.instance); CoreMainMenuDelegate.registerHandler(AddonCalendarMainMenuHandler.instance);
CoreCronDelegate.register(AddonCalendarSyncCronHandler.instance); CoreCronDelegate.register(AddonCalendarSyncCronHandler.instance);

View File

@ -1,112 +1,110 @@
<!-- Add buttons to the nav bar. --> <!-- Add buttons to the nav bar. -->
<core-navbar-buttons slot="end" prepend> <core-navbar-buttons slot="end" prepend>
<core-context-menu> <core-context-menu>
<core-context-menu-item *ngIf="canNavigate && !isCurrentMonth && displayNavButtons" [priority]="900" <core-context-menu-item *ngIf="canNavigate && !selectedMonthIsCurrent() && displayNavButtons" [priority]="900"
[content]="'addon.calendar.currentmonth' | translate" iconAction="fas-calendar-day" [content]="'addon.calendar.currentmonth' | translate" iconAction="fas-calendar-day" (action)="goToCurrentMonth()">
(action)="goToCurrentMonth()"></core-context-menu-item> </core-context-menu-item>
</core-context-menu> </core-context-menu>
</core-navbar-buttons> </core-navbar-buttons>
<core-loading [hideUntil]="loaded" class="safe-area-page"> <core-loading [hideUntil]="loaded">
<!-- Period name and arrows to navigate. --> <div class="core-swipe-slides-container">
<ion-grid class="ion-no-padding addon-calendar-navigation"> <!-- Period name and arrows to navigate. -->
<ion-row class="ion-align-items-center"> <ion-grid class="ion-no-padding addon-calendar-navigation">
<ion-col class="ion-text-start" *ngIf="canNavigate"> <ion-row class="ion-align-items-center">
<ion-button fill="clear" (click)="loadPrevious()" [attr.aria-label]="'core.previous' | translate"> <ion-col class="ion-text-start" *ngIf="canNavigate">
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon> <ion-button fill="clear" (click)="loadPrevious()" [attr.aria-label]="'core.previous' | translate">
</ion-button> <ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-col> </ion-button>
<ion-col class="ion-text-center addon-calendar-period"> </ion-col>
<h2 id="addon-calendar-monthname">{{ periodName }}</h2> <ion-col class="ion-text-center addon-calendar-period">
</ion-col> <h2 id="addon-calendar-monthname">
<ion-col class="ion-text-end" *ngIf="canNavigate"> {{ periodName }}
<ion-button fill="clear" (click)="loadNext()" [attr.aria-label]="'core.next' | translate"> <ion-spinner *ngIf="!selectedMonthLoaded()" class="addon-calendar-loading-month">
<ion-icon name="fas-chevron-right" slot="icon-only" aria-hidden="true"></ion-icon> </ion-spinner>
</ion-button> </h2>
</ion-col> </ion-col>
</ion-row> <ion-col class="ion-text-end" *ngIf="canNavigate">
</ion-grid> <ion-button fill="clear" (click)="loadNext()" [attr.aria-label]="'core.next' | translate">
<ion-icon name="fas-chevron-right" slot="icon-only" aria-hidden="true"></ion-icon>
<!-- Calendar view. --> </ion-button>
<ion-grid class="addon-calendar-months" role="table" aria-describedby="addon-calendar-monthname">
<div role="rowgroup">
<!-- List of days. -->
<ion-row role="row">
<ion-col class="ion-text-center addon-calendar-weekday" *ngFor="let day of weekDays" role="columnheader">
<span class="sr-only">{{ day.fullname | translate }}</span>
<span class="ion-hide-md-up" aria-hidden="true">{{ day.shortname | translate }}</span>
<span class="ion-hide-md-down" aria-hidden="true">{{ day.fullname | translate }}</span>
</ion-col> </ion-col>
</ion-row> </ion-row>
</div> </ion-grid>
<div role="rowgroup">
<!-- Weeks. --> <core-swipe-slides [manager]="manager">
<ion-row *ngFor="let week of weeks" class="addon-calendar-week" role="row"> <ng-template let-month="item">
<!-- Empty slots (first week). --> <!-- Calendar view. -->
<ion-col *ngFor="let value of week.prepadding" class="dayblank addon-calendar-day" role="cell"></ion-col> <ion-grid class="addon-calendar-months" role="table" aria-describedby="addon-calendar-monthname">
<ion-col <div role="rowgroup">
*ngFor="let day of week.days" <!-- List of days. -->
class="addon-calendar-day ion-text-center" <ion-row role="row">
[ngClass]='{ <ion-col class="ion-text-center addon-calendar-weekday" *ngFor="let day of month.weekDays" role="columnheader">
"hasevents": day.hasevents, <span class="sr-only">{{ day.fullname | translate }}</span>
"today": isCurrentMonth && day.istoday, <span class="ion-hide-md-up" aria-hidden="true">{{ day.shortname | translate }}</span>
"weekend": day.isweekend, <span class="ion-hide-md-down" aria-hidden="true">{{ day.fullname | translate }}</span>
"duration_finish": day.haslastdayofevent </ion-col>
}' </ion-row>
[class.addon-calendar-event-past-day]="isPastMonth || day.ispast"
role="cell"
tabindex="0"
(ariaButtonClick)="dayClicked(day.mday)"
>
<p class="addon-calendar-day-number" role="button">
<span aria-hidden="true">{{ day.mday }}</span>
<span class="sr-only">{{ day.periodName | translate }}</span>
</p>
<!-- In phone, display some dots to indicate the type of events. -->
<p class="ion-hide-md-up addon-calendar-dot-types"><span *ngFor="let type of day.calendareventtypes"
class="calendar_event_type calendar_event_{{type}}"></span></p>
<!-- In tablet, display list of events. -->
<div class="ion-hide-md-down addon-calendar-day-events">
<ng-container *ngFor="let event of day.filteredEvents | slice:0:4; let index = index">
<div
*ngIf="index < 3 || day.filteredEvents.length == 4"
class="addon-calendar-event"
[class.addon-calendar-event-past]="event.ispast"
role="button"
tabindex="0"
(ariaButtonClick)="eventClicked(event, $event)"
>
<span class="calendar_event_type calendar_event_{{event.formattedType}}"></span>
<ion-icon *ngIf="event.offline && !event.deleted" name="fas-clock"
[attr.aria-label]="'core.notsent' | translate"></ion-icon>
<ion-icon *ngIf="event.deleted" name="fas-trash"
[attr.aria-label]="'core.deletedoffline' | translate"></ion-icon>
<span class="addon-calendar-event-time">
{{ event.timestart * 1000 | coreFormatDate: timeFormat }}
</span>
<img *ngIf="event.moduleIcon" src="{{event.moduleIcon}}" alt="" role="presentation"
class="core-module-icon">
<!-- Add the icon title so accessibility tools read it. -->
<span class="sr-only">
{{ 'addon.calendar.type' + event.formattedType | translate }}
<span class="sr-only" *ngIf="event.moduleIcon && event.iconTitle">{{ event.iconTitle }}</span>
</span>
<span class="addon-calendar-event-name">{{event.name}}</span>
</div>
</ng-container>
<p *ngIf="day.filteredEvents.length > 4" class="addon-calendar-day-more">
<b>{{ 'core.nummore' | translate:{$a: day.filteredEvents.length - 3} }}</b>
</p>
</div> </div>
</ion-col> <div role="rowgroup">
<!-- Empty slots (last week). --> <!-- Weeks. -->
<ion-col *ngFor="let value of week.postpadding" class="dayblank addon-calendar-day" role="cell"></ion-col> <ion-row *ngFor="let week of month.weeks" class="addon-calendar-week" role="row">
</ion-row> <!-- Empty slots (first week). -->
</div> <ion-col *ngFor="let value of week.prepadding" class="dayblank addon-calendar-day" role="cell">
</ion-grid> </ion-col>
<ion-col *ngFor="let day of week.days" class="addon-calendar-day ion-text-center" [ngClass]='{
"hasevents": day.hasevents,
"today": month.isCurrentMonth && day.istoday,
"weekend": day.isweekend,
"duration_finish": day.haslastdayofevent
}' [class.addon-calendar-event-past-day]="month.isPastMonth || day.ispast" role="cell" tabindex="0"
(ariaButtonClick)="dayClicked(day.mday)">
<p class="addon-calendar-day-number" role="button">
<span aria-hidden="true">{{ day.mday }}</span>
<span class="sr-only">{{ day.periodName | translate }}</span>
</p>
<!-- In phone, display some dots to indicate the type of events. -->
<p class="ion-hide-md-up addon-calendar-dot-types"><span *ngFor="let type of day.calendareventtypes"
class="calendar_event_type calendar_event_{{type}}"></span></p>
<!-- In tablet, display list of events. -->
<div class="ion-hide-md-down addon-calendar-day-events" *ngIf="day.filteredEvents">
<ng-container *ngFor="let event of day.filteredEvents | slice:0:4; let index = index">
<div *ngIf="index < 3 || day.filteredEvents.length == 4" class="addon-calendar-event"
[class.addon-calendar-event-past]="event.ispast" role="button" tabindex="0"
(ariaButtonClick)="eventClicked(event, $event)">
<span class="calendar_event_type calendar_event_{{event.formattedType}}"></span>
<ion-icon *ngIf="event.offline && !event.deleted" name="fas-clock"
[attr.aria-label]="'core.notsent' | translate"></ion-icon>
<ion-icon *ngIf="event.deleted" name="fas-trash"
[attr.aria-label]="'core.deletedoffline' | translate"></ion-icon>
<span class="addon-calendar-event-time">
{{ event.timestart * 1000 | coreFormatDate: timeFormat }}
</span>
<!-- Add the icon title so accessibility tools read it. -->
<span class="sr-only">
{{ 'addon.calendar.type' + event.formattedType | translate }}
<span class="sr-only" *ngIf="event.iconTitle">
{{ event.iconTitle }}
</span>
</span>
<span class="addon-calendar-event-name">{{event.name}}</span>
</div>
</ng-container>
<p *ngIf="day.filteredEvents.length > 4" class="addon-calendar-day-more">
<b>{{ 'core.nummore' | translate:{$a: day.filteredEvents.length - 3} }}</b>
</p>
</div>
</ion-col>
<!-- Empty slots (last week). -->
<ion-col *ngFor="let value of week.postpadding" class="dayblank addon-calendar-day" role="cell">
</ion-col>
</ion-row>
</div>
</ion-grid>
</ng-template>
</core-swipe-slides>
</div>
</core-loading> </core-loading>

View File

@ -1,5 +1,12 @@
@import "~theme/globals";
:host { :host {
--addon-calendar-blank-day-background-color: var(--gray-lighter); --addon-calendar-blank-day-background-color: var(--light);
.core-swipe-slides-container ion-grid {
flex: none;
width: 100%;
}
.addon-calendar-navigation { .addon-calendar-navigation {
padding-top: 5px; padding-top: 5px;
@ -10,22 +17,22 @@
.addon-calendar-months { .addon-calendar-months {
background-color: var(--contrast-background); background-color: var(--contrast-background);
padding: 0; padding: 0;
font-size: 14px; font-size: var(--text-size);
} }
.addon-calendar-day { .addon-calendar-day {
border-bottom: 1px solid var(--addon-calendar-border-color); border-bottom: 1px solid var(--addon-calendar-border-color);
border-right: 1px solid var(--addon-calendar-border-color); @include border-end(1px, solid var(--addon-calendar-border-color));
overflow: hidden; overflow: hidden;
min-height: 60px; min-height: 60px;
cursor: pointer; cursor: pointer;
&:first-child { &:first-child {
padding-left: 10px; @include padding-horizontal(10px, null);
} }
&:last-child { &:last-child {
border-right: 0; @include border-end(0);
padding-left: 8px; @include padding-horizontal(8px, null);
} }
&.addon-calendar-event-past-day > .addon-calendar-dot-types, &.addon-calendar-event-past-day > .addon-calendar-dot-types,
@ -48,7 +55,7 @@
} }
} }
@media (min-width: 768px) { @include media-breakpoint-up(md) {
.addon-calendar-day-number { .addon-calendar-day-number {
text-align: start; text-align: start;
} }
@ -56,7 +63,7 @@
&.today .addon-calendar-day-number span { &.today .addon-calendar-day-number span {
border: 2px solid var(--addon-calendar-today-border-color); border: 2px solid var(--addon-calendar-today-border-color);
line-height: 20px;; line-height: 20px;
border-radius: 50%; border-radius: 50%;
} }
&.dayblank { &.dayblank {
@ -82,9 +89,7 @@
} }
.addon-calendar-day-more { .addon-calendar-day-more {
margin-top: 0.6em; @include margin(0.6em, null, 0.6em, 4px);
margin-bottom: 0.6em;
margin-right: 4px;
} }
.addon-calendar-dot-types { .addon-calendar-dot-types {
@ -98,6 +103,10 @@
margin-top: 10px; margin-top: 10px;
font-size: 1.2rem; font-size: 1.2rem;
} }
.addon-calendar-loading-month {
height: 20px;
}
} }
.addon-calendar-weekday { .addon-calendar-weekday {
@ -106,10 +115,10 @@
} }
.addon-calendar-day-events { .addon-calendar-day-events {
text-align: left; text-align: start;
ion-icon { ion-icon {
margin-right: 2px; @include margin-horizontal(null, 2px);
font-size: 1em; font-size: 1em;
} }
} }
@ -127,66 +136,22 @@
margin-right: 1px; margin-right: 1px;
margin-left: 1px; margin-left: 1px;
&.calendar_event_category { @each $category, $value in $calendar-event-category-colors {
background-color: var(--addon-calendar-event-category-color); &.calendar_event_#{$category} {
} background-color: $value;
&.calendar_event_course { }
background-color: var(--addon-calendar-event-course-color);
}
&.calendar_event_group {
background-color: var(--addon-calendar-event-group-color);
}
&.calendar_event_user {
background-color: var(--addon-calendar-event-user-color);
}
&.calendar_event_site {
background-color: var(--addon-calendar-event-site-color);
} }
} }
.core-module-icon { ion-slide {
margin-right: 1px; display: block;
margin-left: 1px; font-size: inherit;
--size: 16px; justify-content: start;
display: inline-block; align-items: start;
vertical-align: bottom; text-align: start;
}
.core-module-icon[slot="start"] {
padding: 6px;
}
}
:host-context([dir=rtl]) {
.addon-calendar-day-events {
text-align: right;
ion-icon {
margin-right: unset;
margin-left: 2px;
}
}
.addon-calendar-day {
border-left: 1px solid var(--addon-calendar-border-color);
border-right: unset;
&:first-child {
padding-right: 10px;
padding-left: unset;
}
&:last-child {
border-left: 0;
border-right: unset;
padding-right: 8px;
padding-left: unset;
}
.addon-calendar-day-more {
margin-left: 4px;
margin-right: unset;
}
} }
} }
:host-context(body.dark) { :host-context(body.dark) {
--addon-calendar-blank-day-background-color: var(--black); --addon-calendar-blank-day-background-color: var(--gray-900);
} }

View File

@ -22,6 +22,8 @@ import {
EventEmitter, EventEmitter,
KeyValueDiffers, KeyValueDiffers,
KeyValueDiffer, KeyValueDiffer,
ViewChild,
HostBinding,
} from '@angular/core'; } from '@angular/core';
import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
@ -40,7 +42,13 @@ import { AddonCalendarFilter, AddonCalendarHelper } from '../../services/calenda
import { AddonCalendarOffline } from '../../services/calendar-offline'; import { AddonCalendarOffline } from '../../services/calendar-offline';
import { CoreCategoryData, CoreCourses } from '@features/courses/services/courses'; import { CoreCategoryData, CoreCourses } from '@features/courses/services/courses';
import { CoreApp } from '@services/app'; import { CoreApp } from '@services/app';
import { CoreLocalNotifications } from '@services/local-notifications'; import { CoreSwipeSlidesComponent } from '@components/swipe-slides/swipe-slides';
import {
CoreSwipeSlidesDynamicItem,
CoreSwipeSlidesDynamicItemsManagerSource,
} from '@classes/items-management/swipe-slides-dynamic-items-manager-source';
import { CoreSwipeSlidesDynamicItemsManager } from '@classes/items-management/swipe-slides-dynamic-items-manager';
import moment from 'moment';
/** /**
* Component that displays a calendar. * Component that displays a calendar.
@ -52,54 +60,31 @@ import { CoreLocalNotifications } from '@services/local-notifications';
}) })
export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestroy { export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestroy {
@ViewChild(CoreSwipeSlidesComponent) slides?: CoreSwipeSlidesComponent<PreloadedMonth>;
@Input() initialYear?: number; // Initial year to load. @Input() initialYear?: number; // Initial year to load.
@Input() initialMonth?: number; // Initial month to load. @Input() initialMonth?: number; // Initial month to load.
@Input() filter?: AddonCalendarFilter; // Filter to apply. @Input() filter?: AddonCalendarFilter; // Filter to apply.
@Input() hidden?: boolean; // Whether the component is hidden.
@Input() canNavigate?: string | boolean; // Whether to include arrows to change the month. Defaults to true. @Input() canNavigate?: string | boolean; // Whether to include arrows to change the month. Defaults to true.
@Input() displayNavButtons?: string | boolean; // Whether to display nav buttons created by this component. Defaults to true. @Input() displayNavButtons?: string | boolean; // Whether to display nav buttons created by this component. Defaults to true.
@Output() onEventClicked = new EventEmitter<number>(); @Output() onEventClicked = new EventEmitter<number>();
@Output() onDayClicked = new EventEmitter<{day: number; month: number; year: number}>(); @Output() onDayClicked = new EventEmitter<{day: number; month: number; year: number}>();
periodName?: string; periodName?: string;
weekDays: AddonCalendarWeekDaysTranslationKeys[] = []; manager?: CoreSwipeSlidesDynamicItemsManager<PreloadedMonth, AddonCalendarMonthSlidesItemsManagerSource>;
weeks: AddonCalendarWeek[] = [];
loaded = false; loaded = false;
timeFormat?: string;
isCurrentMonth = false;
isPastMonth = false;
protected year?: number;
protected month?: number;
protected categoriesRetrieved = false;
protected categories: { [id: number]: CoreCategoryData } = {};
protected currentSiteId: string; protected currentSiteId: string;
protected offlineEvents: { [monthId: string]: { [day: number]: AddonCalendarEventToDisplay[] } } = protected hiddenDiffer?: boolean; // To detect changes in the hidden input.
{}; // Offline events classified in month & day. protected filterDiffer: KeyValueDiffer<unknown, unknown>; // To detect changes in the filters input.
// Observers and listeners.
protected offlineEditedEventsIds: number[] = []; // IDs of events edited in offline.
protected deletedEvents: number[] = []; // Events deleted in offline.
protected currentTime?: number;
protected differ: KeyValueDiffer<unknown, unknown>; // To detect changes in the data input.
// Observers.
protected undeleteEventObserver: CoreEventObserver; protected undeleteEventObserver: CoreEventObserver;
protected obsDefaultTimeChange?: CoreEventObserver; protected managerUnsubscribe?: () => void;
constructor( constructor(differs: KeyValueDiffers) {
differs: KeyValueDiffers,
) {
this.currentSiteId = CoreSites.getCurrentSiteId(); this.currentSiteId = CoreSites.getCurrentSiteId();
if (CoreLocalNotifications.isAvailable()) {
// Re-schedule events if default time changes.
this.obsDefaultTimeChange = CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => {
this.weeks.forEach((week) => {
week.days.forEach((day) => {
AddonCalendar.scheduleEventsNotifications(day.eventsFormated!);
});
});
}, this.currentSiteId);
}
// Listen for events "undeleted" (offline). // Listen for events "undeleted" (offline).
this.undeleteEventObserver = CoreEvents.on( this.undeleteEventObserver = CoreEvents.on(
AddonCalendarProvider.UNDELETED_EVENT_EVENT, AddonCalendarProvider.UNDELETED_EVENT_EVENT,
@ -112,27 +97,40 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
this.undeleteEvent(data.eventId); this.undeleteEvent(data.eventId);
// Remove it from the list of deleted events if it's there. // Remove it from the list of deleted events if it's there.
const index = this.deletedEvents.indexOf(data.eventId); const index = this.manager?.getSource().deletedEvents.indexOf(data.eventId) ?? -1;
if (index != -1) { if (index != -1) {
this.deletedEvents.splice(index, 1); this.manager?.getSource().deletedEvents.splice(index, 1);
} }
}, },
this.currentSiteId, this.currentSiteId,
); );
this.differ = differs.find([]).create(); this.hiddenDiffer = this.hidden;
this.filterDiffer = differs.find(this.filter ?? {}).create();
}
@HostBinding('attr.hidden') get hiddenAttribute(): string | null {
return this.hidden ? 'hidden' : null;
} }
/** /**
* Component loaded. * Component loaded.
*/ */
ngOnInit(): void { ngOnInit(): void {
const now = new Date(); this.canNavigate = typeof this.canNavigate == 'undefined' ? true : CoreUtils.isTrueOrOne(this.canNavigate);
this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true :
CoreUtils.isTrueOrOne(this.displayNavButtons);
this.year = this.initialYear ? this.initialYear : now.getFullYear(); const source = new AddonCalendarMonthSlidesItemsManagerSource(this, moment({
this.month = this.initialMonth ? this.initialMonth : now.getMonth() + 1; year: this.initialYear,
month: this.initialMonth ? this.initialMonth - 1 : undefined,
this.calculateIsCurrentMonth(); }));
this.manager = new CoreSwipeSlidesDynamicItemsManager(source);
this.managerUnsubscribe = this.manager.addListener({
onSelectedItemUpdated: (item) => {
this.onMonthViewed(item);
},
});
this.fetchData(); this.fetchData();
} }
@ -141,17 +139,31 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
* Detect and act upon changes that Angular cant or wont detect on its own (objects and arrays). * Detect and act upon changes that Angular cant or wont detect on its own (objects and arrays).
*/ */
ngDoCheck(): void { ngDoCheck(): void {
this.canNavigate = typeof this.canNavigate == 'undefined' ? true : CoreUtils.isTrueOrOne(this.canNavigate); const items = this.manager?.getSource().getItems();
this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true :
CoreUtils.isTrueOrOne(this.displayNavButtons);
if (this.weeks) { if (items?.length) {
// Check if there's any change in the filter object. // Check if there's any change in the filter object.
const changes = this.differ.diff(this.filter!); const changes = this.filterDiffer.diff(this.filter ?? {});
if (changes) { if (changes) {
this.filterEvents(); items.forEach((month) => {
if (month.loaded && month.weeks) {
this.manager?.getSource().filterEvents(month.weeks, this.filter);
}
});
} }
} }
if (this.hiddenDiffer !== this.hidden) {
this.hiddenDiffer = this.hidden;
if (!this.hidden) {
this.slides?.slides?.getSwiper().then(swipper => swipper.update());
}
}
}
get timeFormat(): string {
return this.manager?.getSource().timeFormat || 'core.strftimetime';
} }
/** /**
@ -160,41 +172,10 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
async fetchData(): Promise<void> { async fetchData(): Promise<void> {
const promises: Promise<void>[] = [];
promises.push(this.loadCategories());
// Get offline events.
promises.push(AddonCalendarOffline.getAllEditedEvents().then((events) => {
// Classify them by month.
this.offlineEvents = AddonCalendarHelper.classifyIntoMonths(events);
// Get the IDs of events edited in offline.
const filtered = events.filter((event) => event.id! > 0);
this.offlineEditedEventsIds = filtered.map((event) => event.id!);
return;
}));
// Get events deleted in offline.
promises.push(AddonCalendarOffline.getAllDeletedEventsIds().then((ids) => {
this.deletedEvents = ids;
return;
}));
// Get time format to use.
promises.push(AddonCalendar.getCalendarTimeFormat().then((value) => {
this.timeFormat = value;
return;
}));
try { try {
await Promise.all(promises); await this.manager?.getSource().fetchData();
await this.fetchEvents();
await this.manager?.getSource().load(this.manager?.getSelectedItem());
} catch (error) { } catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
} }
@ -203,113 +184,16 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
} }
/** /**
* Fetch the events for current month. * Update data related to month being viewed.
* *
* @return Promise resolved when done. * @param month Month being viewed.
*/ */
async fetchEvents(): Promise<void> { onMonthViewed(month: MonthBasicData): void {
// Don't pass courseId and categoryId, we'll filter them locally.
let result: { daynames: Partial<AddonCalendarDayName>[]; weeks: Partial<AddonCalendarWeek>[] };
try {
result = await AddonCalendar.getMonthlyEvents(this.year!, this.month!);
} catch (error) {
if (!CoreApp.isOnline()) {
// Allow navigating to non-cached months in offline (behave as if using emergency cache).
result = await AddonCalendarHelper.getOfflineMonthWeeks(this.year!, this.month!);
} else {
throw error;
}
}
// Calculate the period name. We don't use the one in result because it's in server's language. // Calculate the period name. We don't use the one in result because it's in server's language.
this.periodName = CoreTimeUtils.userDate( this.periodName = CoreTimeUtils.userDate(
new Date(this.year!, this.month! - 1).getTime(), month.moment.unix() * 1000,
'core.strftimemonthyear', 'core.strftimemonthyear',
); );
this.weekDays = AddonCalendar.getWeekDays(result.daynames[0].dayno);
this.weeks = result.weeks as AddonCalendarWeek[];
this.calculateIsCurrentMonth();
this.weeks.forEach((week) => {
week.days.forEach((day) => {
day.periodName = CoreTimeUtils.userDate(
new Date(this.year!, this.month! - 1, day.mday).getTime(),
'core.strftimedaydate',
);
day.eventsFormated = day.eventsFormated || [];
day.filteredEvents = day.filteredEvents || [];
day.events.forEach((event) => {
/// Format online events.
day.eventsFormated!.push(AddonCalendarHelper.formatEventData(event));
});
});
});
if (this.isCurrentMonth) {
const currentDay = new Date().getDate();
let isPast = true;
this.weeks.forEach((week) => {
week.days.forEach((day) => {
day.istoday = day.mday == currentDay;
day.ispast = isPast && !day.istoday;
isPast = day.ispast;
if (day.istoday) {
day.eventsFormated!.forEach((event) => {
event.ispast = this.isEventPast(event);
});
}
});
});
}
// Merge the online events with offline data.
this.mergeEvents();
// Filter events by course.
this.filterEvents();
}
/**
* Load categories to be able to filter events.
*
* @return Promise resolved when done.
*/
protected async loadCategories(): Promise<void> {
if (this.categoriesRetrieved) {
// Already retrieved, stop.
return;
}
try {
const cats = await CoreCourses.getCategories(0, true);
this.categoriesRetrieved = true;
this.categories = {};
// Index categories by ID.
cats.forEach((category) => {
this.categories[category.id] = category;
});
} catch {
// Ignore errors.
}
}
/**
* Filter events based on the filter popover.
*/
filterEvents(): void {
this.weeks.forEach((week) => {
week.days.forEach((day) => {
day.filteredEvents = AddonCalendarHelper.getFilteredEvents(
day.eventsFormated!,
this.filter!,
this.categories,
);
// Re-calculate some properties.
AddonCalendarHelper.calculateDayData(day, day.filteredEvents);
});
});
} }
/** /**
@ -318,55 +202,30 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
* @param afterChange Whether the refresh is done after an event has changed or has been synced. * @param afterChange Whether the refresh is done after an event has changed or has been synced.
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
async refreshData(afterChange?: boolean): Promise<void> { async refreshData(afterChange = false): Promise<void> {
const promises: Promise<void>[] = []; const selectedMonth = this.manager?.getSelectedItem() || null;
// Don't invalidate monthly events after a change, it has already been handled. if (afterChange) {
if (!afterChange) { this.manager?.getSource().markAllItemsDirty();
promises.push(AddonCalendar.invalidateMonthlyEvents(this.year!, this.month!));
} }
promises.push(CoreCourses.invalidateCategories(0, true));
promises.push(AddonCalendar.invalidateTimeFormat());
this.categoriesRetrieved = false; // Get categories again. await this.manager?.getSource().invalidateContent(selectedMonth);
await Promise.all(promises); await this.fetchData();
this.fetchData();
} }
/** /**
* Load next month. * Load next month.
*/ */
async loadNext(): Promise<void> { loadNext(): void {
this.increaseMonth(); this.slides?.slideNext();
this.loaded = false;
try {
await this.fetchEvents();
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
this.decreaseMonth();
}
this.loaded = true;
} }
/** /**
* Load previous month. * Load previous month.
*/ */
async loadPrevious(): Promise<void> { loadPrevious(): void {
this.decreaseMonth(); this.slides?.slidePrev();
this.loaded = false;
try {
await this.fetchEvents();
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
this.increaseMonth();
}
this.loaded = true;
} }
/** /**
@ -386,106 +245,53 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
* @param day Day. * @param day Day.
*/ */
dayClicked(day: number): void { dayClicked(day: number): void {
this.onDayClicked.emit({ day: day, month: this.month!, year: this.year! }); const selectedMonth = this.manager?.getSelectedItem();
} if (!selectedMonth) {
return;
}
/** this.onDayClicked.emit({ day: day, month: selectedMonth.moment.month() + 1, year: selectedMonth.moment.year() });
* Check if user is viewing the current month.
*/
calculateIsCurrentMonth(): void {
const now = new Date();
this.currentTime = CoreTimeUtils.timestamp();
this.isCurrentMonth = this.year == now.getFullYear() && this.month == now.getMonth() + 1;
this.isPastMonth = this.year! < now.getFullYear() || (this.year == now.getFullYear() && this.month! < now.getMonth() + 1);
} }
/** /**
* Go to current month. * Go to current month.
*/ */
async goToCurrentMonth(): Promise<void> { async goToCurrentMonth(): Promise<void> {
const now = new Date(); const manager = this.manager;
const initialMonth = this.month; const slides = this.slides;
const initialYear = this.year; if (!manager || !slides) {
return;
this.month = now.getMonth() + 1; }
this.year = now.getFullYear();
const currentMonth = {
moment: moment(),
};
this.loaded = false; this.loaded = false;
try { try {
await this.fetchEvents(); // Make sure the day is loaded.
this.isCurrentMonth = true; await manager.getSource().loadItem(currentMonth);
slides.slideToItem(currentMonth);
} catch (error) { } catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
this.year = initialYear; } finally {
this.month = initialMonth; this.loaded = true;
}
this.loaded = true;
}
/**
* Decrease the current month.
*/
protected decreaseMonth(): void {
if (this.month === 1) {
this.month = 12;
this.year!--;
} else {
this.month!--;
} }
} }
/** /**
* Increase the current month. * Check whether selected month is loaded.
*/ */
protected increaseMonth(): void { selectedMonthLoaded(): boolean {
if (this.month === 12) { return !!this.manager?.getSelectedItem()?.loaded;
this.month = 1;
this.year!++;
} else {
this.month!++;
}
} }
/** /**
* Merge online events with the offline events of that period. * Check whether selected month is current month.
*/ */
protected mergeEvents(): void { selectedMonthIsCurrent(): boolean {
const monthOfflineEvents: { [day: number]: AddonCalendarEventToDisplay[] } = return !!this.manager?.getSelectedItem()?.isCurrentMonth;
this.offlineEvents[AddonCalendarHelper.getMonthId(this.year!, this.month!)];
this.weeks.forEach((week) => {
week.days.forEach((day) => {
// Schedule notifications for the events retrieved (only future events will be scheduled).
AddonCalendar.scheduleEventsNotifications(day.eventsFormated!);
if (monthOfflineEvents || this.deletedEvents.length) {
// There is offline data, merge it.
if (this.deletedEvents.length) {
// Mark as deleted the events that were deleted in offline.
day.eventsFormated!.forEach((event) => {
event.deleted = this.deletedEvents.indexOf(event.id) != -1;
});
}
if (this.offlineEditedEventsIds.length) {
// Remove the online events that were modified in offline.
day.events = day.events.filter((event) => this.offlineEditedEventsIds.indexOf(event.id) == -1);
}
if (monthOfflineEvents && monthOfflineEvents[day.mday]) {
// Add the offline events (either new or edited).
day.eventsFormated =
AddonCalendarHelper.sortEvents(day.eventsFormated!.concat(monthOfflineEvents[day.mday]));
}
}
});
});
} }
/** /**
@ -493,17 +299,293 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
* *
* @param eventId Event ID. * @param eventId Event ID.
*/ */
protected undeleteEvent(eventId: number): void { protected async undeleteEvent(eventId: number): Promise<void> {
if (!this.weeks) { this.manager?.getSource().getItems()?.some((month) => {
return; if (!month.loaded) {
} return false;
}
this.weeks.forEach((week) => { return month.weeks?.some((week) => week.days.some((day) => {
week.days.forEach((day) => { const event = day.eventsFormated?.find((event) => event.id == eventId);
const event = day.eventsFormated!.find((event) => event.id == eventId);
if (event) { if (event) {
event.deleted = false; event.deleted = false;
return true;
}
return false;
}));
});
}
/**
* Component destroyed.
*/
ngOnDestroy(): void {
this.undeleteEventObserver?.off();
this.manager?.destroy();
this.managerUnsubscribe && this.managerUnsubscribe();
}
}
/**
* Basic data to identify a month.
*/
type MonthBasicData = {
moment: moment.Moment;
};
/**
* Preloaded month.
*/
type PreloadedMonth = MonthBasicData & CoreSwipeSlidesDynamicItem & {
weekDays?: AddonCalendarWeekDaysTranslationKeys[];
weeks?: AddonCalendarWeek[];
isCurrentMonth?: boolean;
isPastMonth?: boolean;
};
/**
* Helper to manage swiping within months.
*/
class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicItemsManagerSource<PreloadedMonth> {
categories?: { [id: number]: CoreCategoryData };
// Offline events classified in month & day.
offlineEvents: { [monthId: string]: { [day: number]: AddonCalendarEventToDisplay[] } } = {};
offlineEditedEventsIds: number[] = []; // IDs of events edited in offline.
deletedEvents: number[] = []; // Events deleted in offline.
timeFormat?: string;
protected calendarComponent: AddonCalendarCalendarComponent;
constructor(component: AddonCalendarCalendarComponent, initialMoment: moment.Moment) {
super({ moment: initialMoment });
this.calendarComponent = component;
}
/**
* Fetch data.
*
* @return Promise resolved when done.
*/
async fetchData(): Promise<void> {
await Promise.all([
this.loadCategories(),
this.loadOfflineEvents(),
this.loadOfflineDeletedEvents(),
this.loadTimeFormat(),
]);
}
/**
* Filter events based on the filter popover.
*
* @param weeks Weeks with the events to filter.
* @param filter Filter to apply.
*/
filterEvents(weeks: AddonCalendarWeek[], filter?: AddonCalendarFilter): void {
weeks.forEach((week) => {
week.days.forEach((day) => {
day.filteredEvents = AddonCalendarHelper.getFilteredEvents(
day.eventsFormated || [],
filter,
this.categories || {},
);
// Re-calculate some properties.
AddonCalendarHelper.calculateDayData(day, day.filteredEvents);
});
});
}
/**
* Load categories to be able to filter events.
*
* @return Promise resolved when done.
*/
async loadCategories(): Promise<void> {
if (this.categories) {
// Already retrieved, stop.
return;
}
try {
const categories = await CoreCourses.getCategories(0, true);
// Index categories by ID.
this.categories = CoreUtils.arrayToObject(categories, 'id');
} catch {
// Ignore errors.
}
}
/**
* Load events created or edited in offline.
*
* @return Promise resolved when done.
*/
async loadOfflineEvents(): Promise<void> {
// Get offline events.
const events = await AddonCalendarOffline.getAllEditedEvents();
// Classify them by month.
this.offlineEvents = AddonCalendarHelper.classifyIntoMonths(events);
// Get the IDs of events edited in offline.
this.offlineEditedEventsIds = events.filter((event) => event.id > 0).map((event) => event.id);
}
/**
* Load events deleted in offline.
*
* @return Promise resolved when done.
*/
async loadOfflineDeletedEvents(): Promise<void> {
this.deletedEvents = await AddonCalendarOffline.getAllDeletedEventsIds();
}
/**
* Load time format.
*
* @return Promise resolved when done.
*/
async loadTimeFormat(): Promise<void> {
this.timeFormat = await AddonCalendar.getCalendarTimeFormat();
}
/**
* @inheritdoc
*/
getItemId(item: MonthBasicData): string | number {
return AddonCalendarHelper.getMonthId(item.moment);
}
/**
* @inheritdoc
*/
getPreviousItem(item: MonthBasicData): MonthBasicData | null {
return {
moment: item.moment.clone().subtract(1, 'month'),
};
}
/**
* @inheritdoc
*/
getNextItem(item: MonthBasicData): MonthBasicData | null {
return {
moment: item.moment.clone().add(1, 'month'),
};
}
/**
* @inheritdoc
*/
async loadItemData(month: MonthBasicData, preload = false): Promise<PreloadedMonth | null> {
// Load or preload the weeks.
let result: { daynames: Partial<AddonCalendarDayName>[]; weeks: Partial<AddonCalendarWeek>[] };
const year = month.moment.year();
const monthNumber = month.moment.month() + 1;
if (preload) {
result = await AddonCalendarHelper.getOfflineMonthWeeks(year, monthNumber);
} else {
try {
// Don't pass courseId and categoryId, we'll filter them locally.
result = await AddonCalendar.getMonthlyEvents(year, monthNumber);
} catch (error) {
if (!CoreApp.isOnline()) {
// Allow navigating to non-cached months in offline (behave as if using emergency cache).
result = await AddonCalendarHelper.getOfflineMonthWeeks(year, monthNumber);
} else {
throw error;
}
}
}
const weekDays = AddonCalendar.getWeekDays(result.daynames[0].dayno);
const weeks = result.weeks as AddonCalendarWeek[];
const currentDay = new Date().getDate();
const currentTime = CoreTimeUtils.timestamp();
const preloadedMonth: PreloadedMonth = {
...month,
weeks,
weekDays,
isCurrentMonth: month.moment.isSame(moment(), 'month'),
isPastMonth: month.moment.isBefore(moment(), 'month'),
};
await Promise.all(weeks.map(async (week) => {
await Promise.all(week.days.map(async (day) => {
day.periodName = CoreTimeUtils.userDate(
month.moment.unix() * 1000,
'core.strftimedaydate',
);
day.eventsFormated = day.eventsFormated || [];
day.filteredEvents = day.filteredEvents || [];
// Format online events.
const onlineEventsFormatted = await Promise.all(
day.events.map(async (event) => AddonCalendarHelper.formatEventData(event)),
);
day.eventsFormated = day.eventsFormated.concat(onlineEventsFormatted);
if (preloadedMonth.isCurrentMonth) {
day.istoday = day.mday == currentDay;
day.ispast = preloadedMonth.isPastMonth || day.mday < currentDay;
if (day.istoday) {
day.eventsFormated?.forEach((event) => {
event.ispast = this.isEventPast(event, currentTime);
});
}
}
}));
}));
if (!preload) {
// Merge the online events with offline data.
this.mergeEvents(month, weeks);
// Filter events by course.
this.filterEvents(weeks, this.calendarComponent.filter);
}
return preloadedMonth;
}
/**
* Merge online events with the offline events of that period.
*
* @param month Month.
* @param weeks Weeks with the events to filter.
*/
mergeEvents(month: MonthBasicData, weeks: AddonCalendarWeek[]): void {
const monthOfflineEvents: { [day: number]: AddonCalendarEventToDisplay[] } =
this.offlineEvents[AddonCalendarHelper.getMonthId(month.moment)];
weeks.forEach((week) => {
week.days.forEach((day) => {
if (this.deletedEvents.length) {
// Mark as deleted the events that were deleted in offline.
day.eventsFormated?.forEach((event) => {
event.deleted = this.deletedEvents.indexOf(event.id) != -1;
});
}
if (this.offlineEditedEventsIds.length) {
// Remove the online events that were modified in offline.
day.events = day.events.filter((event) => this.offlineEditedEventsIds.indexOf(event.id) == -1);
}
if (monthOfflineEvents && monthOfflineEvents[day.mday] && day.eventsFormated) {
// Add the offline events (either new or edited).
day.eventsFormated =
AddonCalendarHelper.sortEvents(day.eventsFormated.concat(monthOfflineEvents[day.mday]));
} }
}); });
}); });
@ -513,18 +595,35 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
* Returns if the event is in the past or not. * Returns if the event is in the past or not.
* *
* @param event Event object. * @param event Event object.
* @param currentTime Current time.
* @return True if it's in the past. * @return True if it's in the past.
*/ */
protected isEventPast(event: { timestart: number; timeduration: number}): boolean { isEventPast(event: { timestart: number; timeduration: number}, currentTime: number): boolean {
return (event.timestart + event.timeduration) < this.currentTime!; return (event.timestart + event.timeduration) < currentTime;
} }
/** /**
* Component destroyed. * Invalidate content.
*
* @param selectedMonth The current selected month.
* @return Promise resolved when done.
*/ */
ngOnDestroy(): void { async invalidateContent(selectedMonth: PreloadedMonth | null): Promise<void> {
this.undeleteEventObserver?.off(); const promises: Promise<void>[] = [];
this.obsDefaultTimeChange?.off();
if (selectedMonth) {
promises.push(AddonCalendar.invalidateMonthlyEvents(selectedMonth.moment.year(), selectedMonth.moment.month() + 1));
}
promises.push(CoreCourses.invalidateCategories(0, true));
promises.push(AddonCalendar.invalidateTimeFormat());
this.categories = undefined; // Get categories again.
if (selectedMonth) {
selectedMonth.dirty = true;
}
await Promise.all(promises);
} }
} }

View File

@ -18,13 +18,15 @@ import { CoreSharedModule } from '@/core/shared.module';
import { AddonCalendarCalendarComponent } from './calendar/calendar'; import { AddonCalendarCalendarComponent } from './calendar/calendar';
import { AddonCalendarUpcomingEventsComponent } from './upcoming-events/upcoming-events'; import { AddonCalendarUpcomingEventsComponent } from './upcoming-events/upcoming-events';
import { AddonCalendarFilterPopoverComponent } from './filter/filter'; import { AddonCalendarFilterComponent } from './filter/filter';
import { AddonCalendarReminderTimeModalComponent } from './reminder-time-modal/reminder-time-modal';
@NgModule({ @NgModule({
declarations: [ declarations: [
AddonCalendarCalendarComponent, AddonCalendarCalendarComponent,
AddonCalendarUpcomingEventsComponent, AddonCalendarUpcomingEventsComponent,
AddonCalendarFilterPopoverComponent, AddonCalendarFilterComponent,
AddonCalendarReminderTimeModalComponent,
], ],
imports: [ imports: [
CoreSharedModule, CoreSharedModule,
@ -34,7 +36,8 @@ import { AddonCalendarFilterPopoverComponent } from './filter/filter';
exports: [ exports: [
AddonCalendarCalendarComponent, AddonCalendarCalendarComponent,
AddonCalendarUpcomingEventsComponent, AddonCalendarUpcomingEventsComponent,
AddonCalendarFilterPopoverComponent, AddonCalendarFilterComponent,
AddonCalendarReminderTimeModalComponent,
], ],
}) })
export class AddonCalendarComponentsModule {} export class AddonCalendarComponentsModule {}

View File

@ -1,16 +0,0 @@
<ion-list>
<ion-item *ngFor="let type of types" class="addon-calendar-event" [ngClass]="['addon-calendar-eventtype-'+type]">
<ion-icon [name]="typeIcons[type]" slot="start" aria-hidden="true"></ion-icon>
<ion-label>{{ 'addon.calendar.' + type + 'events' | translate}}</ion-label>
<ion-toggle [(ngModel)]="filter[type]" (ionChange)="onChange()" slot="end"></ion-toggle>
</ion-item>
<core-spacer *ngIf="filter.course || filter.category || filter.group"></core-spacer>
<ng-container *ngIf="filter.course || filter.category || filter.group">
<ion-radio-group [(ngModel)]="courseId" (ionChange)="onChange()">
<ion-item class="ion-text-wrap" *ngFor="let course of courses">
<ion-label><core-format-text [text]="course.fullname"></core-format-text></ion-label>
<ion-radio slot="end" [value]="course.id"></ion-radio>
</ion-item>
</ion-radio-group>
</ng-container>
</ion-list>

View File

@ -1,21 +0,0 @@
:host {
ion-item {
ion-icon, ion-radio {
margin-right: 8px;
}
> ion-icon {
padding: 4px;
font-size: 20px;
}
}
}
:host-context([dir=rtl]) {
ion-item {
ion-icon, ion-radio {
margin-left: 8px;
margin-right: unset;
}
}
}

View File

@ -0,0 +1,29 @@
<ion-header class="no-title">
<ion-toolbar>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="fas-times" slot="icon-only" aria-hidden=true></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content [fullscreen]="true">
<ion-list>
<ion-item *ngFor="let type of types" class="addon-calendar-event" [ngClass]="['addon-calendar-eventtype-'+type]">
<ion-icon [name]="typeIcons[type]" slot="start" aria-hidden="true"></ion-icon>
<ion-label>{{ 'addon.calendar.' + type + 'events' | translate}}</ion-label>
<ion-toggle [(ngModel)]="filter[type]" (ionChange)="onChange()" slot="end"></ion-toggle>
</ion-item>
<core-spacer *ngIf="filter.course || filter.category || filter.group"></core-spacer>
<ng-container *ngIf="filter.course || filter.category || filter.group">
<ion-radio-group [(ngModel)]="courseId" (ionChange)="onChange()">
<ion-item class="ion-text-wrap" *ngFor="let course of courses">
<ion-label>
<core-format-text [text]="course.fullname"></core-format-text>
</ion-label>
<ion-radio slot="end" [value]="course.id"></ion-radio>
</ion-item>
</ion-radio-group>
</ng-container>
</ion-list>
</ion-content>

View File

@ -0,0 +1,14 @@
@import "~theme/globals";
:host {
ion-item {
ion-icon, ion-radio {
@include margin-horizontal(null, 8px);
}
> ion-icon {
padding: 4px;
font-size: 20px;
}
}
}

View File

@ -15,6 +15,7 @@
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { CoreEnrolledCourseData } from '@features/courses/services/courses'; import { CoreEnrolledCourseData } from '@features/courses/services/courses';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { ModalController } from '@singletons';
import { CoreEvents } from '@singletons/events'; import { CoreEvents } from '@singletons/events';
import { AddonCalendarEventType, AddonCalendarProvider } from '../../services/calendar'; import { AddonCalendarEventType, AddonCalendarProvider } from '../../services/calendar';
import { AddonCalendarFilter, AddonCalendarEventIcons } from '../../services/calendar-helper'; import { AddonCalendarFilter, AddonCalendarEventIcons } from '../../services/calendar-helper';
@ -23,11 +24,11 @@ import { AddonCalendarFilter, AddonCalendarEventIcons } from '../../services/cal
* Component to display the events filter that includes events types and a list of courses. * Component to display the events filter that includes events types and a list of courses.
*/ */
@Component({ @Component({
selector: 'addon-calendar-filter-popover', selector: 'addon-calendar-filter',
templateUrl: 'addon-calendar-filter-popover.html', templateUrl: 'filter.html',
styleUrls: ['../../calendar-common.scss', 'filter-popover.scss'], styleUrls: ['../../calendar-common.scss', 'filter.scss'],
}) })
export class AddonCalendarFilterPopoverComponent implements OnInit { export class AddonCalendarFilterComponent implements OnInit {
@Input() filter: AddonCalendarFilter = { @Input() filter: AddonCalendarFilter = {
filtered: false, filtered: false,
@ -56,7 +57,7 @@ export class AddonCalendarFilterPopoverComponent implements OnInit {
} }
/** /**
* Init the component. * @inheritdoc
*/ */
ngOnInit(): void { ngOnInit(): void {
this.courseId = this.filter.courseId || -1; this.courseId = this.filter.courseId || -1;
@ -80,4 +81,11 @@ export class AddonCalendarFilterPopoverComponent implements OnInit {
CoreEvents.trigger(AddonCalendarProvider.FILTER_CHANGED_EVENT, this.filter); CoreEvents.trigger(AddonCalendarProvider.FILTER_CHANGED_EVENT, this.filter);
} }
/**
* Close modal.
*/
closeModal(): void {
ModalController.dismiss();
}
} }

View File

@ -0,0 +1,62 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h2>{{ 'addon.calendar.reminders' | translate }}</h2>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fas-times" aria-hidden="true"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<form (ngSubmit)="saveReminder()">
<ion-radio-group name="radiovalue" [(ngModel)]="radioValue" class="ion-text-wrap">
<!-- Preset options. -->
<ion-item *ngIf="allowDisable">
<ion-label>
<p>{{ 'core.settings.disabled' | translate }}</p>
</ion-label>
<ion-radio slot="end" value="disabled"></ion-radio>
</ion-item>
<ion-item *ngFor="let option of presetOptions">
<ion-label>
<p>{{ option.label }}</p>
</ion-label>
<ion-radio slot="end" [value]="option.radioValue"></ion-radio>
</ion-item>
<!-- Custom value. -->
<ion-item class="ion-text-wrap">
<ion-label>
<p>{{ 'core.custom' | translate }}</p>
</ion-label>
<ion-radio slot="end" value="custom"></ion-radio>
</ion-item>
<ion-item class="ion-text-wrap" (click)="customInputClicked($event)">
<ion-label></ion-label>
<div class="flex-row">
<!-- Input to enter the value. -->
<ion-input type="number" name="customvalue" [(ngModel)]="customValue" [disabled]="radioValue != 'custom'"
placeholder="10" (click)="customInputClicked($event)">
</ion-input>
<!-- Units. -->
<label class="accesshide" for="reminderUnits">{{ 'addon.calendar.units' | translate }}</label>
<ion-select id="reminderUnits" name="customunits" [(ngModel)]="customUnits" interface="action-sheet"
[disabled]="radioValue != 'custom'" slot="end" [interfaceOptions]="{header: 'addon.calendar.units' | translate}">
<ion-select-option *ngFor="let option of customUnitsOptions" [value]="option.value">
{{ option.label | translate }}
</ion-select-option>
</ion-select>
</div>
</ion-item>
</ion-radio-group>
<ion-button type="submit" class="ion-margin" expand="block" [disabled]="radioValue == 'custom' && !customValue">
{{ 'core.done' | translate }}
</ion-button>
</form>
</ion-content>

View File

@ -0,0 +1,174 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { AddonCalendar, AddonCalendarReminderUnits, AddonCalendarValueAndUnit } from '@addons/calendar/services/calendar';
import { Component, Input, OnInit } from '@angular/core';
import { CoreDomUtils } from '@services/utils/dom';
import { ModalController } from '@singletons';
/**
* Modal to choose a reminder time.
*/
@Component({
selector: 'addon-calendar-new-reminder-modal',
templateUrl: 'reminder-time-modal.html',
})
export class AddonCalendarReminderTimeModalComponent implements OnInit {
@Input() initialValue?: AddonCalendarValueAndUnit;
@Input() allowDisable?: boolean;
radioValue = '5m';
customValue = '10';
customUnits = AddonCalendarReminderUnits.MINUTE;
presetOptions = [
{
radioValue: '5m',
value: 5,
unit: AddonCalendarReminderUnits.MINUTE,
label: '',
},
{
radioValue: '10m',
value: 10,
unit: AddonCalendarReminderUnits.MINUTE,
label: '',
},
{
radioValue: '30m',
value: 30,
unit: AddonCalendarReminderUnits.MINUTE,
label: '',
},
{
radioValue: '1h',
value: 1,
unit: AddonCalendarReminderUnits.HOUR,
label: '',
},
{
radioValue: '12h',
value: 12,
unit: AddonCalendarReminderUnits.HOUR,
label: '',
},
{
radioValue: '1d',
value: 1,
unit: AddonCalendarReminderUnits.DAY,
label: '',
},
];
customUnitsOptions = [
{
value: AddonCalendarReminderUnits.MINUTE,
label: 'core.minutes',
},
{
value: AddonCalendarReminderUnits.HOUR,
label: 'core.hours',
},
{
value: AddonCalendarReminderUnits.DAY,
label: 'core.days',
},
{
value: AddonCalendarReminderUnits.WEEK,
label: 'core.weeks',
},
];
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
this.presetOptions.forEach((option) => {
option.label = AddonCalendar.getUnitValueLabel(option.value, option.unit);
});
if (!this.initialValue) {
return;
}
if (this.initialValue.value === 0) {
this.radioValue = 'disabled';
} else {
// Search if it's one of the preset options.
const option = this.presetOptions.find(option =>
option.value === this.initialValue?.value && option.unit === this.initialValue.unit);
if (option) {
this.radioValue = option.radioValue;
} else {
// It's a custom value.
this.radioValue = 'custom';
this.customValue = String(this.initialValue.value);
this.customUnits = this.initialValue.unit;
}
}
}
/**
* Close the modal.
*/
closeModal(): void {
ModalController.dismiss();
}
/**
* Save the reminder.
*/
saveReminder(): void {
if (this.radioValue === 'disabled') {
ModalController.dismiss(0);
} else if (this.radioValue === 'custom') {
const value = parseInt(this.customValue, 10);
if (!value) {
CoreDomUtils.showErrorModal('core.errorinvalidform', true);
return;
}
ModalController.dismiss(Math.abs(value) * this.customUnits);
} else {
const option = this.presetOptions.find(option => option.radioValue === this.radioValue);
if (!option) {
return;
}
ModalController.dismiss(option.unit * option.value);
}
}
/**
* Custom value input clicked.
*
* @param ev Click event.
*/
async customInputClicked(ev: Event): Promise<void> {
if (this.radioValue === 'custom') {
return;
}
this.radioValue = 'custom';
const target = <HTMLInputElement | HTMLElement | null> ev.target;
if (target) {
CoreDomUtils.focusElement(target);
}
}
}

Some files were not shown because too many files have changed in this diff Show More