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,
},
],
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-redeclare': 'error',
'@typescript-eslint/no-this-alias': 'error',
'@typescript-eslint/no-unused-vars': 'error',
@ -139,7 +139,6 @@ const appConfig = {
'always',
],
'@typescript-eslint/type-annotation-spacing': 'error',
'@typescript-eslint/unified-signatures': 'error',
'header/header': [
2,
'line',
@ -235,6 +234,11 @@ const appConfig = {
prev: '*',
next: 'return',
},
{
blankLine: 'always',
prev: '*',
next: 'function',
},
],
'prefer-arrow/prefer-arrow-functions': [
'error',
@ -271,6 +275,7 @@ testsConfig['rules']['padded-blocks'] = [
switches: 'never',
},
];
testsConfig['rules']['jest/expect-expect'] = 'off';
testsConfig['plugins'].push('jest');
testsConfig['extends'].push('plugin:jest/recommended');
@ -291,6 +296,7 @@ module.exports = {
'@angular-eslint/template/no-positive-tabindex': 'error',
'@angular-eslint/template/accessibility-table-scope': '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
uses: actions/setup-node@v1
with:
node-version: '12.x'
node-version: '14.x'
- 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: 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
uses: actions/setup-node@v1
with:
node-version: '12.x'
node-version: '14'
- name: Install npm packages
run: npm ci
run: |
npm install -g npm@7
npm ci --no-audit
- name: Check langindex
run: |
result=$(cat scripts/langindex.json | grep \"TBD\" | wc -l); test $result -eq 0
@ -49,11 +51,11 @@ jobs:
echo "Found $found missing langkeys"
exit 1
fi
- name: Run Linter
run: npm run lint
- name: Run Linter (ignore warnings)
run: npm run lint -- --quiet
- name: Run tests
run: npm run test:ci
- name: Production builds
run: npm run build:prod
- 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
dist: trusty
node_js: 12
node_js: 14
git:
depth: 3
@ -18,12 +18,12 @@ cache:
- $HOME/.android/build-cache
before_install:
- nvm install 12
- nvm install
- npm install npm@^7 -g
- node --version
- npm --version
- nvm --version
- npm ci
- npm install npm@^6 -g
before_script:
- npx gulp
@ -46,16 +46,30 @@ jobs:
- extra-google-google_play_services
- extra-google-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:
apt:
packages:
- libsecret-1-dev
- php5-cli
- php5-common
- stage: build
name: "Build iOS"
language: node_js
if: env(BUILD_IOS) = 1 AND (env(DEPLOY) = 1 OR (env(DEPLOY) = 2 AND tag IS NOT blank))
os: osx
osx_image: xcode12.5
osx_image: xcode13.1
addons:
homebrew:
packages:
- jq
- stage: test
name: "End to end tests (mod_forum and mod_messages)"
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": {
"moodle.config.json": "jsonc",
"moodle.config.*.json": "jsonc",

View File

@ -6,7 +6,8 @@ WORKDIR /app
# Prepare node dependencies
RUN apt-get update && apt-get install libsecret-1-0 -y
COPY package*.json ./
RUN npm ci
RUN npm install -g npm@7
RUN npm ci --no-audit
# Build source
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)
* [Developer documentation](http://docs.moodle.org/dev/Moodle_Mobile)
* [Development environment setup](http://docs.moodle.org/dev/Setting_up_your_development_environment_for_Moodle_Mobile_2)
* [User documentation](https://docs.moodle.org/en/Moodle_app)
* [Developer documentation](http://docs.moodle.org/dev/Moodle_App)
* [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)
* [Release Notes](http://docs.moodle.org/dev/Moodle_Mobile_Release_Notes)
* [Release Notes](https://docs.moodle.org/dev/Moodle_App_Release_Notes)
License
-------
[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": {},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"builder": "@angular-builders/custom-webpack:browser",
"options": {
"customWebpackConfig": {
"path": "./webpack.config.js"
},
"allowedCommonJsDependencies":[
"chart.js"
],
"outputPath": "www",
"index": "src/index.html",
"main": "src/main.ts",
@ -55,11 +61,25 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "50mb",
"maximumError": "100mb"
"maximumWarning": "5mb",
"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": {
"progress": false
}

View File

@ -1,5 +1,5 @@
<?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>
<description>Moodle official app</description>
<author email="mobile@moodle.com" href="http://moodle.com">Moodle Mobile team</author>
@ -27,7 +27,7 @@
<preference name="UIWebViewBounce" value="false" />
<preference name="DisallowOverscroll" 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="ScrollEnabled" value="false" />
<preference name="KeyboardDisplayRequiresUserAction" value="false" />
@ -47,6 +47,11 @@
<preference name="iosPersistentFileLocation" value="Compatibility" />
<preference name="iosScheme" value="moodleappfs" />
<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">
<param name="ios-package" onload="true" value="CDVStatusBar" />
</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-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/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']">
<activity android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|screenLayout|smallestScreenSize" />
</edit-config>
<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>
<config-file parent="/manifest/application" target="AndroidManifest.xml">
<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" />
</feature>
</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">
<feature name="InAppBrowser">
<param name="android-package" value="org.apache.cordova.inappbrowser.InAppBrowser" />
@ -185,12 +186,6 @@
<param name="onload" value="true" />
</feature>
</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">
<feature name="SQLitePlugin">
<param name="android-package" value="io.sqlc.SQLitePlugin" />
@ -256,7 +251,7 @@
<true />
</edit-config>
<edit-config file="*-Info.plist" mode="merge" target="CFBundleShortVersionString">
<string>3.9.5</string>
<string>4.0.0</string>
</edit-config>
<edit-config file="*-Info.plist" mode="overwrite" target="CFBundleLocalizations">
<array>
@ -288,6 +283,9 @@
<config-file parent="NSCrossWebsiteTrackingUsageDescription" target="*-Info.plist">
<string>This app needs third party cookies to correctly render embedded content from the Moodle site.</string>
</config-file>
<config-file parent="ITSAppUsesNonExemptEncryption" target="*-Info.plist">
<false />
</config-file>
<config-file parent="CFBundleDocumentTypes" target="*-Info.plist">
<array>
<dict>

View File

@ -69,3 +69,7 @@ gulp.task('watch', () => {
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",
"appname": "Moodle Mobile",
"versioncode": 3950,
"versionname": "3.9.5",
"versioncode": 40000,
"versionname": "4.0.0",
"cache_update_frequency_usually": 420000,
"cache_update_frequency_often": 1200000,
"cache_update_frequency_sometimes": 3600000,
@ -30,6 +30,7 @@
"he": "עברית",
"hi": "हिंदी",
"hr": "Hrvatski",
"hsb": "Hornjoserbsski",
"hu": "magyar",
"hy": "Հայերեն",
"id": "Indonesian",
@ -38,6 +39,7 @@
"km": "ខ្មែរ",
"kn": "ಕನ್ನಡ",
"ko": "한국어",
"lo": "ລາວ",
"lt": "Lietuvių",
"lv": "Latviešu",
"mn": "Монгол",
@ -62,15 +64,14 @@
"zh-tw": "正體中文"
},
"wsservice": "moodle_mobile_app",
"wsextservice": "local_mobile",
"demo_sites": {
"student": {
"url": "https:\/\/school.moodledemo.net",
"url": "https://school.moodledemo.net",
"username": "student",
"password": "moodle"
},
"teacher": {
"url": "https:\/\/school.moodledemo.net",
"url": "https://school.moodledemo.net",
"username": "teacher",
"password": "moodle"
}
@ -88,7 +89,7 @@
"onlyallowlistedsites": false,
"skipssoconfirmation": false,
"forcedefaultlanguage": false,
"privacypolicy": "https:\/\/moodle.net\/moodle-app-privacy\/",
"privacypolicy": "https://moodle.net/moodle-app-privacy/",
"notificoncolor": "#f98012",
"enableanalytics": false,
"enableonboarding": true,
@ -98,5 +99,8 @@
"appstores": {
"android": "com.moodle.moodlemobile",
"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",
"version": "3.9.5",
"version": "4.0.0",
"description": "The official app for Moodle.",
"author": {
"name": "Moodle Pty Ltd.",
@ -8,7 +8,7 @@
},
"repository": {
"type": "git",
"url": "https://github.com/moodlehq/moodlemobile2.git"
"url": "https://github.com/moodlehq/moodleapp.git"
},
"license": "Apache-2.0",
"licenses": [
@ -19,21 +19,22 @@
],
"scripts": {
"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: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:ios": "ionic cordova run ios --livereload",
"prod:android": "NODE_ENV=production ionic cordova run android --aot",
"prod:ios": "NODE_ENV=production ionic cordova run ios --aot",
"dev:ios": "ionic cordova run ios",
"prod:android": "NODE_ENV=production ionic cordova run android --prod",
"prod:ios": "NODE_ENV=production ionic cordova run ios --prod",
"test": "NODE_ENV=testing gulp && jest --verbose",
"test:ci": "NODE_ENV=testing gulp && jest -ci --runInBand --verbose",
"test:watch": "NODE_ENV=testing gulp watch & jest --watch",
"test:coverage": "NODE_ENV=testing gulp && jest --coverage",
"lint": "NODE_OPTIONS=--max-old-space-size=4096 ng lint",
"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"
},
"dependencies": {
@ -70,65 +71,61 @@
"@ionic-native/status-bar": "5.33.0",
"@ionic-native/web-intent": "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/http-loader": "6.0.0",
"@types/chart.js": "2.9.31",
"@types/cordova": "0.0.34",
"@types/cordova-plugin-file-transfer": "1.6.2",
"@types/dom-mediacapture-record": "1.0.7",
"chart.js": "2.9.4",
"com-darryncampbell-cordova-plugin-intent": "1.3.0",
"cordova": "10.0.0",
"cordova-android": "9.1.0",
"cordova-android-support-gradle-release": "3.0.1",
"com-darryncampbell-cordova-plugin-intent": "2.2.0",
"cordova": "11.0.0",
"cordova-android": "10.1.1",
"cordova-clipboard": "1.3.0",
"cordova-ios": "6.2.0",
"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-camera": "5.0.1",
"cordova-plugin-camera": "6.0.0",
"cordova-plugin-chooser": "1.3.2",
"cordova-plugin-customurlscheme": "5.0.2",
"cordova-plugin-device": "2.0.3",
"cordova-plugin-file": "6.0.2",
"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-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-webview": "5.0.0",
"cordova-plugin-local-notification": "git+https://github.com/moodlemobile/cordova-plugin-local-notification.git#moodle",
"cordova-plugin-media": "5.0.3",
"cordova-plugin-media": "5.0.4",
"cordova-plugin-media-capture": "3.0.3",
"cordova-plugin-network-information": "2.0.2",
"cordova-plugin-qrscanner": "git+https://github.com/moodlemobile/cordova-plugin-qrscanner.git#dist",
"cordova-plugin-screen-orientation": "3.0.2",
"cordova-plugin-network-information": "3.0.0",
"cordova-plugin-prevent-override": "1.0.1",
"cordova-plugin-splashscreen": "6.0.0",
"cordova-plugin-statusbar": "2.4.3",
"cordova-plugin-whitelist": "1.3.4",
"cordova-plugin-wkuserscript": "git+https://github.com/moodlemobile/cordova-plugin-wkuserscript.git",
"cordova-plugin-wkwebview-cookies": "git+https://github.com/moodlemobile/cordova-plugin-wkwebview-cookies.git",
"cordova-plugin-zip": "3.1.0",
"cordova-plugin-statusbar": "3.0.0",
"cordova-plugin-wkuserscript": "1.0.1",
"cordova-plugin-wkwebview-cookies": "1.0.1",
"cordova-sqlite-storage": "6.0.0",
"cordova-support-google-services": "1.3.2",
"cordova.plugins.diagnostic": "5.0.2",
"cordova.plugins.diagnostic": "6.1.1",
"core-js": "3.9.1",
"es6-promise-plugin": "4.2.2",
"jszip": "3.5.0",
"hammerjs": "2.0.8",
"jszip": "3.7.1",
"mathjax": "2.7.7",
"moment": "2.29.0",
"moment": "2.29.2",
"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",
"ts-md5": "1.2.7",
"tslib": "2.0.1",
"tslib": "2.3.1",
"zone.js": "0.10.3"
},
"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-eslint/builder": "4.2.0",
"@angular-eslint/eslint-plugin": "4.2.0",
@ -140,7 +137,7 @@
"@angular/compiler-cli": "10.0.14",
"@angular/language-service": "10.0.14",
"@ionic/angular-toolkit": "2.3.3",
"@ionic/cli": "6.14.1",
"@ionic/cli": "6.19.0",
"@types/faker": "5.1.3",
"@types/node": "12.12.64",
"@types/resize-observer-browser": "0.1.5",
@ -148,7 +145,9 @@
"@typescript-eslint/eslint-plugin": "4.22.0",
"@typescript-eslint/parser": "4.22.0",
"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-config-prettier": "8.3.0",
"eslint-plugin-header": "3.1.1",
@ -169,13 +168,14 @@
"jest": "26.5.2",
"jest-preset-angular": "8.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-node": "8.3.0",
"typescript": "3.9.9"
},
"engines": {
"node": ">=12.x"
"node": ">=14.15.0 <15"
},
"cordova": {
"platforms": [
@ -183,11 +183,14 @@
"ios"
],
"plugins": {
"cordova-plugin-advanced-http": {},
"cordova-plugin-advanced-http": {
"ANDROIDBLACKLISTSECURESOCKETPROTOCOLS": "SSLv3,TLSv1"
},
"cordova-clipboard": {},
"cordova-plugin-badge": {},
"cordova-plugin-camera": {
"ANDROID_SUPPORT_V4_VERSION": "27.+"
"ANDROID_SUPPORT_V4_VERSION": "27.+",
"ANDROIDX_CORE_VERSION": "1.6.+"
},
"cordova-plugin-chooser": {},
"cordova-plugin-customurlscheme": {
@ -203,10 +206,10 @@
"cordova-plugin-geolocation": {
"GPS_REQUIRED": "false"
},
"cordova-plugin-inappbrowser": {},
"@moodlehq/cordova-plugin-inappbrowser": {},
"cordova-plugin-ionic-keyboard": {},
"cordova-plugin-ionic-webview": {},
"cordova-plugin-local-notification": {
"@moodlehq/cordova-plugin-ionic-webview": {},
"@moodlehq/cordova-plugin-local-notification": {
"ANDROID_SUPPORT_V4_VERSION": "26.+"
},
"cordova-plugin-media-capture": {},
@ -214,30 +217,29 @@
"KEEP_AVAUDIOSESSION_ALWAYS_ACTIVE": "NO"
},
"cordova-plugin-network-information": {},
"cordova-plugin-qrscanner": {},
"cordova-plugin-screen-orientation": {},
"@moodlehq/cordova-plugin-qrscanner": {},
"cordova-plugin-splashscreen": {},
"cordova-plugin-statusbar": {},
"cordova-plugin-whitelist": {},
"cordova-plugin-wkuserscript": {},
"cordova-plugin-wkwebview-cookies": {},
"cordova-plugin-zip": {},
"@moodlehq/cordova-plugin-zip": {},
"cordova-sqlite-storage": {},
"phonegap-plugin-push": {
"ANDROID_SUPPORT_V13_VERSION": "27.+",
"FCM_VERSION": "17.0.+"
"@moodlehq/phonegap-plugin-push": {
"ANDROID_SUPPORT_V13_VERSION": "28.0.0",
"FCM_VERSION": "18.+",
"IOS_FIREBASE_MESSAGING_VERSION": "~> 6.32.2"
},
"com-darryncampbell-cordova-plugin-intent": {},
"nl.kingsquare.cordova.background-audio": {},
"cordova-android-support-gradle-release": {
"ANDROID_SUPPORT_VERSION": "27.+"
},
"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": {},
"cordova-plugin-file-transfer": {},
"cordova-plugin-prevent-override": {}
"@moodlehq/cordova-plugin-file-transfer": {},
"cordova-plugin-prevent-override": {},
"cordova-plugin-androidx-adapter": {},
"cordova-plugin-screen-orientation": {}
}
},
"optionalDependencies": {

View File

@ -1,6 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
</domain-config>
</network-security-config>
<base-config cleartextTrafficPermitted="true" />
</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 );
ksort($config['languages']);
$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) {
$folder = LANGPACKSFOLDER.'/'.str_replace('-', '_', $lang);
if (!is_dir($folder) || !is_file($folder.'/langconfig.php')) {
@ -246,9 +253,11 @@ function build_lang($lang, $keys) {
}
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('}', '}}', $text);
$text = preg_replace('/@@.+?@@(<br>)?\\s*/', '', $text);
// Prevent double.
$text = str_replace(array('{{{', '}}}'), array('{{', '}}'), $text);
} else {
@ -270,7 +279,7 @@ function build_lang($lang, $keys) {
// Sort and save.
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);
$percentage = floor($success/$total * 100);
@ -365,7 +374,7 @@ function save_key($key, $value, $filePath) {
if (!isset($file[$key]) || $file[$key] != $value) {
$file[$key] = $value;
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
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
unzip -o -u $lang.zip > /dev/null
@ -114,6 +123,11 @@ function get_language {
# Entry function to get all language files.
function get_languages {
suffix=$1
if [ -z $suffix ]; then
suffix=''
fi
get_last_version
if [ -d $LANGPACKSFOLDER ]; then
@ -131,6 +145,7 @@ function get_languages {
if [ $AWS_SERVICE -eq 1 ]; then
get_all_languages_aws
suffix=''
else
echo "Fallback language list will only get current installation languages"
get_installed_languages
@ -138,5 +153,9 @@ function get_languages {
for lang in $langs; do
get_language "$lang"
if [ $suffix != '' ]; then
get_language "$lang$suffix"
fi
done
}

View File

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

View File

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

View File

@ -44,8 +44,7 @@ const mainMenuRoutes: Routes = [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
useValue: () => {
CoreContentLinksDelegate.registerHandler(AddonBadgesMyBadgesLinkHandler.instance);
CoreContentLinksDelegate.registerHandler(AddonBadgesBadgeLinkHandler.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",
"language": "Language",
"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.",
"recipientdetails": "Recipient details",
"relatedbages": "Related badges",
"version": "Version",
"warnexpired": "(This badge has expired!)"
}
}

View File

@ -3,11 +3,13 @@
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<h1 *ngIf="badge">{{ badge.name }}</h1>
<h1 *ngIf="!badge">{{ 'addon.badges.badges' | translate }}</h1>
<ion-title>
<h1 *ngIf="badge">{{ badge.name }}</h1>
<h1 *ngIf="!badge">{{ 'addon.badges.badges' | translate }}</h1>
</ion-title>
</ion-toolbar>
</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-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
@ -53,7 +55,9 @@
<ion-item class="ion-text-wrap" *ngIf="badge.issuercontact">
<ion-label>
<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-item>
</ion-item-group>
@ -97,7 +101,9 @@
<ion-item class="ion-text-wrap" *ngIf="badge.imageauthoremail">
<ion-label>
<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-item>
<ion-item class="ion-text-wrap" *ngIf="badge.imageauthorurl">
@ -153,7 +159,9 @@
<!-- Endorsement -->
<ion-item-group *ngIf="badge.endorsement">
<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 class="ion-text-wrap" *ngIf="badge.endorsement.issuername">
<ion-label>
@ -165,7 +173,7 @@
<ion-label>
<h2>{{ 'addon.badges.issueremail' | translate}}</h2>
<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 }}
</a>
</p>
@ -200,27 +208,39 @@
<!-- Related badges -->
<ion-item-group *ngIf="badge.relatedbadges">
<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 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 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-group>
<!-- Competencies alignment -->
<ion-item-group *ngIf="badge.alignment">
<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 class="ion-text-wrap" *ngFor="let alignment of badge.alignment" [href]="alignment.targeturl" core-link
auto-login="no">
<ion-label><h2>{{ alignment.targetname }}</h2></ion-label>
<ion-label>
<h2>{{ alignment.targetname }}</h2>
</ion-label>
</ion-item>
<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-group>
</ng-container>

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { IonRefresher } from '@ionic/angular';
import { CoreTimeUtils } from '@services/utils/time';
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 { CoreNavigator } from '@services/navigator';
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.
@ -31,7 +34,7 @@ import { ActivatedRoute } from '@angular/router';
selector: 'page-addon-badges-issued-badge',
templateUrl: 'issued-badge.html',
})
export class AddonBadgesIssuedBadgePage implements OnInit {
export class AddonBadgesIssuedBadgePage implements OnInit, OnDestroy {
protected badgeHash = '';
protected userId!: number;
@ -40,24 +43,39 @@ export class AddonBadgesIssuedBadgePage implements OnInit {
user?: CoreUserProfile;
course?: CoreEnrolledCourseData;
badge?: AddonBadgesUserBadge;
badges: CoreSwipeNavigationItemsManager;
badgeLoaded = false;
currentTime = 0;
constructor(
protected route: ActivatedRoute,
) { }
constructor(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.
*/
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.badgeLoaded = true;
});
this.badges.start();
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.badges.destroy();
}
/**

View File

@ -3,7 +3,9 @@
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<h1>{{ 'addon.badges.badges' | translate }}</h1>
<ion-title>
<h1>{{ 'addon.badges.badges' | translate }}</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
@ -12,8 +14,7 @@
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="badges.loaded">
<core-empty-box *ngIf="badges.empty" icon="fas-trophy"
[message]="'addon.badges.nobadges' | translate">
<core-empty-box *ngIf="badges.empty" icon="fas-trophy" [message]="'addon.badges.nobadges' | translate">
</core-empty-box>
<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 { CoreSites } from '@services/sites';
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 { 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.
@ -34,15 +35,23 @@ import { CoreNavigator } from '@services/navigator';
export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy {
currentTime = 0;
badges: AddonBadgesUserBadgesManager;
badges: CoreListItemsManager<AddonBadgesUserBadge, AddonBadgesUserBadgesSource>;
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
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();
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.
*/
async refreshBadges(refresher?: IonRefresher): Promise<void> {
await CoreUtils.ignoreErrors(AddonBadges.invalidateUserBadges(this.badges.courseId, this.badges.userId));
await CoreUtils.ignoreErrors(this.fetchBadges());
await CoreUtils.ignoreErrors(
AddonBadges.invalidateUserBadges(
this.badges.getSource().COURSE_ID,
this.badges.getSource().USER_ID,
),
);
await CoreUtils.ignoreErrors(this.badges.reload());
refresher?.complete();
}
@ -80,55 +94,12 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy {
this.currentTime = CoreTimeUtils.timestamp();
try {
await this.fetchBadges();
await this.badges.reload();
} catch (message) {
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> {
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;
// 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.
delete badge.alignment;
}
@ -194,7 +194,7 @@ export type AddonBadgesUserBadge = {
targetframework?: string; // Target framework.
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.
badgeid?: number; // Badge id.
targetname?: string; // Target name.

View File

@ -14,8 +14,14 @@
import { Injectable } from '@angular/core';
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 { CoreSites } from '@services/sites';
import { makeSingleton } from '@singletons';
import { AddonBadges } from '../badges';
@ -25,52 +31,58 @@ import { AddonBadges } from '../badges';
@Injectable({ providedIn: 'root' })
export class AddonBadgesUserHandlerService implements CoreUserProfileHandler {
name = 'AddonBadges';
priority = 50;
name = 'AddonBadges:fakename'; // This name doesn't match any disabled feature, they'll be checked in isEnabledForContext.
priority = 300;
type = CoreUserDelegateService.TYPE_NEW_PAGE;
/**
* Check if handler is enabled.
*
* @return Always enabled.
* @inheritdoc
*/
isEnabled(): Promise<boolean> {
return AddonBadges.isPluginEnabled();
}
/**
* Check if handler is enabled for this user in this context.
*
* @param courseId Course ID.
* @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions.
* @return True if enabled, false otherwise.
* @inheritdoc
*/
async isEnabledForCourse(
async isEnabledForContext(
context: CoreUserDelegateContext,
courseId: number,
navOptions?: CoreCourseUserAdminOrNavOptionIndexed,
): 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;
}
// If we reach here, it means we are opening the user site profile.
return true;
}
/**
* Returns the data needed to render the handler.
*
* @return Data needed to render the handler.
* @inheritdoc
*/
getDisplayData(): CoreUserProfileHandlerData {
return {
icon: 'fas-trophy',
title: 'addon.badges.badges',
action: (event, user, courseId): void => {
action: (event, user, context, contextId): void => {
event.preventDefault();
event.stopPropagation();
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 { CoreUtils } from '@services/utils/utils';
import { CoreNavigator } from '@services/navigator';
import { CoreCourseHelper } from '@features/course/services/course-helper';
/**
* Component to render an "activity modules" block.
@ -28,6 +29,7 @@ import { CoreNavigator } from '@services/navigator';
@Component({
selector: 'addon-block-activitymodules',
templateUrl: 'addon-block-activitymodules.html',
styleUrls: ['activitymodules.scss'],
})
export class AddonBlockActivityModulesComponent extends CoreBlockBaseComponent implements OnInit {
@ -66,14 +68,14 @@ export class AddonBlockActivityModulesComponent extends CoreBlockBaseComponent i
}
section.modules.forEach((mod) => {
if (mod.uservisible === false || !CoreCourse.moduleHasView(mod) ||
typeof modFullNames[mod.modname] != 'undefined') {
if (!CoreCourseHelper.canUserViewModule(mod, section) || !CoreCourse.moduleHasView(mod) ||
modFullNames[mod.modname] !== undefined) {
// Ignore this module.
return;
}
// Get the archetype of the module type.
if (typeof archetypes[mod.modname] == 'undefined') {
if (archetypes[mod.modname] === undefined) {
archetypes[mod.modname] = CoreCourseModuleDelegate.supportsFeature<number>(
mod.modname,
CoreConstants.FEATURE_MOD_ARCHETYPE,
@ -96,16 +98,13 @@ export class AddonBlockActivityModulesComponent extends CoreBlockBaseComponent i
// Sort the modnames alphabetically.
modFullNames = CoreUtils.sortValues(modFullNames);
for (const modName in modFullNames) {
let icon: string;
const iconModName = modName === 'resources' ? 'page' : modName;
if (modName === 'resources') {
icon = CoreCourse.getModuleIconSrc('page', modIcons['page']);
} else {
icon = CoreCourseModuleDelegate.getModuleIconSrc(modName, modIcons[modName]) || '';
}
const icon = await CoreCourseModuleDelegate.getModuleIconSrc(iconModName, modIcons[iconModName]);
this.entries.push({
icon: icon,
icon,
iconModName,
name: modFullNames[modName],
modName,
});
@ -145,4 +144,5 @@ type AddonBlockActivityModuleEntry = {
icon: string;
name: string;
modName: string;
iconModName: string;
};

View File

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

View File

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

View File

@ -1,21 +1,42 @@
:host .core-block-content ::ng-deep {
ul.badges {
list-style: none;
margin-left: 0;
margin-right: 0;
-webkit-padding-start: 0;
:host {
--badge-size: 100px;
--badge-container-size: 150px;
li {
position: relative;
display: inline-block;
padding-top: 1em;
text-align: center;
vertical-align: top;
width: 150px;
.core-block-content ::ng-deep {
.badge-name {
display: block;
padding: 5px;
ul.badges {
list-style: none;
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 { AddonBlockCommentsModule } from './comments/comments.module';
import { AddonBlockCompletionStatusModule } from './completionstatus/completionstatus.module';
import { AddonBlockCourseListModule } from './courselist/courselist.module';
import { AddonBlockGlossaryRandomModule } from './glossaryrandom/glossaryrandom.module';
import { AddonBlockHtmlModule } from './html/html.module';
import { AddonBlockLearningPlansModule } from './learningplans/learningplans.module';
@ -53,6 +54,7 @@ import { AddonBlockTimelineModule } from './timeline/timeline.module';
AddonBlockCalendarUpcomingModule,
AddonBlockCommentsModule,
AddonBlockCompletionStatusModule,
AddonBlockCourseListModule,
AddonBlockGlossaryRandomModule,
AddonBlockHtmlModule,
AddonBlockLearningPlansModule,

View File

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

View File

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

View File

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

View File

@ -4,97 +4,132 @@
</ion-label>
<div slot="end" class="flex-row">
<!-- Download all courses. -->
<div *ngIf="downloadCoursesEnabled && downloadEnabled && filteredCourses.length > 1 && !showFilter"
class="core-button-spinner">
<ion-button *ngIf="!prefetchCoursesData[selectedFilter].loading" fill="clear" color="dark" (click)="prefetchCourses()"
[attr.aria-label]="'core.courses.downloadcourses' | translate">
<ion-icon [name]="prefetchCoursesData[selectedFilter].icon" slot="icon-only" aria-hidden="true">
<div *ngIf="downloadCoursesEnabled && filteredCourses.length > 0" class="core-button-spinner">
<ion-button *ngIf="!prefetchCoursesData.loading" fill="clear" (click)="prefetchCourses()"
[attr.aria-label]="prefetchCoursesData.statusTranslatable | 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[selectedFilter].badge"
role="progressbar" [attr.aria-valuemax]="prefetchCoursesData[selectedFilter].total"
[attr.aria-valuenow]="prefetchCoursesData[selectedFilter].count"
[attr.aria-valuetext]="prefetchCoursesData[selectedFilter].badgeA11yText">
{{prefetchCoursesData[selectedFilter].badge}}
<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[selectedFilter].loading" [attr.aria-label]="'core.loading' | translate">
<ion-spinner *ngIf="prefetchCoursesData.loading" [attr.aria-label]="'core.loading' | translate">
</ion-spinner>
</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>
</ion-item-divider>
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="margin">
<div class="safe-padding-horizontal" [hidden]="showFilter || !showSelectorFilter">
<!-- "Time" selector. -->
<core-combobox [label]="'core.show' | translate" [selection]="selectedFilter" (onChange)="selectedChanged($event)">
<ion-select-option class="ion-text-wrap" value="allincludinghidden" *ngIf="showFilters.allincludinghidden != 'hidden'">
{{ 'addon.block_myoverview.allincludinghidden' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="all" *ngIf="showFilters.all != 'hidden'">
{{ 'addon.block_myoverview.all' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="inprogress" *ngIf="showFilters.inprogress != 'hidden'"
[disabled]="showFilters.inprogress == 'disabled'">
{{ 'addon.block_myoverview.inprogress' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="future" *ngIf="showFilters.future != 'hidden'"
[disabled]="showFilters.future == 'disabled'">
{{ 'addon.block_myoverview.future' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="past" *ngIf="showFilters.past != 'hidden'"
[disabled]="showFilters.past == 'disabled'">
{{ 'addon.block_myoverview.past' | translate }}
</ion-select-option>
<ng-container *ngIf="showFilters.custom != 'hidden'">
<ng-container *ngFor="let customOption of customFilter; let index = index">
<ion-select-option class="ion-text-wrap" value="custom-{{index}}">{{ customOption.name }}</ion-select-option>
<core-loading [hideUntil]="loaded">
<ion-row class="ion-hide-md-up addon-block-myoverview-filter" *ngIf="hasCourses">
<ion-col>
<!-- Filter courses. -->
<ion-searchbar [(ngModel)]="textFilter" (ionInput)="filterTextChanged($event.target)"
(ionCancel)="filterTextChanged($event.target)" [placeholder]="'core.courses.filtermycourses' | translate">
</ion-searchbar>
</ion-col>
</ion-row>
<ion-row class="ion-justify-content-between ion-align-items-center addon-block-myoverview-filter" *ngIf="hasCourses">
<ion-col size="auto" *ngIf="filters.enabled">
<core-combobox [label]="'core.courses.filtermycourses' | translate" [selection]="filters.timeFilterSelected"
(onChange)="filterOptionsChanged($event)">
<ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="allincludinghidden"
*ngIf="filters.show.allincludinghidden">
{{ 'addon.block_myoverview.allincludinghidden' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="all" *ngIf="filters.show.all">
{{ 'addon.block_myoverview.all' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap"
[class.core-select-option-border-bottom]="!filters.show.past && !filters.show.future" value="inprogress"
*ngIf="filters.show.inprogress">
{{ '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>
<ion-select-option class="ion-text-wrap" value="favourite" *ngIf="showFilters.favourite != 'hidden'"
[disabled]="showFilters.favourite == 'disabled'">
{{ 'addon.block_myoverview.favourites' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="hidden" *ngIf="showFilters.hidden != 'hidden'"
[disabled]="showFilters.hidden == 'disabled'">
{{ 'addon.block_myoverview.hiddencourses' | translate }}
</ion-select-option>
</core-combobox>
</div>
<ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="favourite" *ngIf="filters.show.favourite">
{{ 'addon.block_myoverview.favourites' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="hidden" *ngIf="filters.show.hidden">
{{ 'addon.block_myoverview.hiddencourses' | translate }}
</ion-select-option>
</core-combobox>
</ion-col>
<ion-col>
<!-- Filter courses. -->
<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. -->
<ion-searchbar #searchbar *ngIf="showFilter" [(ngModel)]="courses.filter" (ionInput)="filterChanged($event)"
(ionCancel)="filterChanged($event)" [placeholder]="'core.courses.filtermycourses' | translate">
</ion-searchbar>
<core-empty-box *ngIf="filteredCourses.length == 0" image="assets/img/icons/courses.svg"
[message]="'addon.block_myoverview.nocourses' | translate" inline="true">
<core-empty-box *ngIf="filteredCourses.length == 0" image="assets/img/icons/courses.svg">
<p *ngIf="hasCourses" class="item-heading">
{{'addon.block_myoverview.noresult' | translate}}
</p>
<p *ngIf="!hasCourses" class="item-heading">
{{'addon.block_myoverview.nocoursesenrolled' | translate}}
</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>
<!-- List of courses. -->
<div class="safe-area-page">
<ion-grid class="ion-no-padding">
<div class="safe-area-padding" *ngIf="hasCourses">
<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-col *ngFor="let course of filteredCourses" class="ion-no-padding"
size="12" size-sm="6" size-md="6" size-lg="4" size-xl="3">
<core-courses-course-progress [course]="course" class="core-courseoverview" showAll="true"
[showDownload]="downloadCourseEnabled && downloadEnabled">
</core-courses-course-progress>
<ion-col *ngFor="let course of filteredCourses" class="ion-no-padding" size="12" size-sm="6" size-md="6" size-lg="4"
size-xl="3">
<core-courses-course-list-item [course]="course" class="core-courseoverview" [showDownload]="downloadCourseEnabled"
[layout]="layout">
</core-courses-course-list-item>
</ion-col>
</ion-row>
</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)",
"allincludinghidden": "All",
"all": "All",
"allincludinghidden": "All (including archived)",
"browseallcourses": "Browse all courses",
"card": "Card",
"favourites": "Starred",
"future": "Future",
"hiddencourses": "Removed from view",
"hiddencourses": "Archived",
"inprogress": "In progress",
"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",
"pluginname": "Course overview",
"shortname": "Short name",

View File

@ -1,3 +1,5 @@
@import "~theme/globals";
:host .core-block-content ::ng-deep {
max-height: 200px;
overflow-y: auto;
@ -17,23 +19,22 @@
list-style-type: none;
.user {
float: left;
@include float(start);
position: relative;
padding-bottom: 16px;
.core-adapted-img-container {
display: inline;
margin-left: 0;
margin-right: 8px;
@include margin-horizontal(0px, 8px);
}
.userpicture {
vertical-align: text-bottom;
border-radius: 50%;
}
}
.message {
float: right;
@include float(end);
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 { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block';
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';
/**
@ -39,7 +39,7 @@ export class AddonBlockPrivateFilesHandlerService extends CoreBlockBaseHandler {
title: 'addon.block_privatefiles.pluginname',
class: 'addon-block-private-files',
component: CoreBlockOnlyTitleComponent,
link: AddonPrivateFilesMainMenuHandlerService.PAGE_NAME,
link: AddonPrivateFilesUserHandlerService.PAGE_NAME,
linkParams: { root: 'my' },
navOptions: {
preferCurrentTab: false,

View File

@ -1,3 +1,5 @@
@import "~theme/globals";
:host .core-block-content ::ng-deep {
.activitydate, .activityhead {
text-align: center;
@ -12,14 +14,8 @@
margin-bottom: 1em;
.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-label>
<h2>{{ 'addon.block_recentlyaccessedcourses.pluginname' | translate }}</h2>
<h2>{{ 'addon.block_recentlyaccessedcourses.pluginname' | translate }}</h2>
</ion-label>
<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>
</div>
</ion-item-divider>
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="safe-area-page margin">
<core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg" inline="true"
<core-loading [hideUntil]="loaded">
<core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg"
[message]="'addon.block_recentlyaccessedcourses.nocourses' | translate"></core-empty-box>
<!-- List of courses. -->
<div
[id]="scrollElementId"
class="core-horizontal-scroll"
(scroll)="scrollControls.updateScrollPosition()"
>
<div [hidden]="courses.length === 0" [id]="scrollElementId" class="core-horizontal-scroll"
(scroll)="scrollControls.updateScrollPosition()">
<div (onResize)="scrollControls.updateScrollPosition()" class="flex-row">
<div class="safe-area-pseudo-padding-start"></div>
<ng-container *ngFor="let course of courses">
<core-courses-course-progress [course]="course" class="core-recentlyaccessedcourses"
[showDownload]="downloadCourseEnabled && downloadEnabled"></core-courses-course-progress>
<core-courses-course-list-item [course]="course" class="core-recentlyaccessedcourses" layout="summarycard">
</core-courses-course-list-item>
</ng-container>
<div class="safe-area-pseudo-padding-end"></div>
</div>
</div>
</core-loading>

View File

@ -12,17 +12,25 @@
// See the License for the specific language governing permissions and
// 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 { CoreSites } from '@services/sites';
import { CoreCoursesProvider, CoreCoursesMyCoursesUpdatedEventData, CoreCourses } from '@features/courses/services/courses';
import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper';
import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course-helper';
import {
CoreCoursesProvider,
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 { AddonCourseCompletion } from '@/addons/coursecompletion/services/coursecompletion';
import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion';
import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component';
import { CoreUtils } from '@services/utils/utils';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreSite } from '@classes/site';
/**
* Component to render a recent courses block.
@ -31,36 +39,25 @@ import { CoreDomUtils } from '@services/utils/dom';
selector: 'addon-block-recentlyaccessedcourses',
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;
protected prefetchIconsInitialized = false;
protected site!: CoreSite;
protected isDestroyed = false;
protected coursesObserver?: CoreEventObserver;
protected updateSiteObserver?: CoreEventObserver;
protected courseIds = [];
protected fetchContentDefaultError = 'Error getting recent courses data.';
constructor() {
super('AddonBlockRecentlyAccessedCoursesComponent');
this.site = CoreSites.getRequiredCurrentSite();
}
/**
* Component being initialized.
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
// Generate unique id for scroll element.
@ -68,175 +65,149 @@ export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseCom
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(
CoreCoursesProvider.EVENT_MY_COURSES_UPDATED,
(data) => {
if (this.shouldRefreshOnUpdatedEvent(data)) {
this.refreshCourseList();
}
this.refreshCourseList(data);
},
CoreSites.getCurrentSiteId(),
this.site.getId(),
);
super.ngOnInit();
}
/**
* Detect changes on input properties.
*/
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.
* @inheritdoc
*/
protected async invalidateContent(): Promise<void> {
const promises: Promise<void>[] = [];
const courseIds = this.courses.map((course) => course.id);
promises.push(CoreCourses.invalidateUserCourses().finally(() =>
// 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;
});
await this.invalidateCourses(courseIds);
}
/**
* Fetch the courses for recent courses.
* Invalidate list of courses.
*
* @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> {
const showCategories = this.block.configsRecord && this.block.configsRecord.displaycategories &&
this.block.configsRecord.displaycategories.value == '1';
this.courses = await CoreCoursesHelper.getUserCoursesWithOptions('lastaccess', 10, undefined, showCategories);
this.initPrefetchCoursesIcons();
}
/**
* Refresh the list of courses.
*
* @return Promise resolved when done.
*/
protected async refreshCourseList(): Promise<void> {
CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_REFRESHED);
let recentCourses: CoreCourseSummaryData[] = [];
try {
await CoreCourses.invalidateUserCourses();
} catch (error) {
// Ignore errors.
}
recentCourses = await CoreCourses.getRecentCourses();
} catch {
// 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;
}
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.
* @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.
*/
async prefetchCourses(): Promise<void> {
const initialIcon = this.prefetchCoursesData.icon;
protected async refreshCourseList(data: CoreCoursesMyCoursesUpdatedEventData): Promise<void> {
if (data.action == CoreCoursesProvider.ACTION_ENROL) {
// Always update if user enrolled in a course.
return await this.refreshContent();
}
try {
await CoreCourseHelper.prefetchCourses(this.courses, this.prefetchCoursesData);
} catch (error) {
if (!this.isDestroyed) {
CoreDomUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true);
this.prefetchCoursesData.icon = initialIcon;
const courseIndex = this.courses.findIndex((course) => course.id == data.courseId);
const course = this.courses[courseIndex];
if (data.action == CoreCoursesProvider.ACTION_VIEW && data.courseId != CoreSites.getCurrentSiteHomeId()) {
if (!course) {
// Not found, use WS update.
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 {
this.isDestroyed = true;
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-label><h2>{{ 'addon.block_recentlyaccesseditems.pluginname' | translate }}</h2></ion-label>
<ion-label>
<h2>{{ 'addon.block_recentlyaccesseditems.pluginname' | translate }}</h2>
</ion-label>
<div slot="end">
<core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId">
</core-horizontal-scroll-controls>
</div>
</ion-item-divider>
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="safe-area-page">
<div
[id]="scrollElementId"
[hidden]="!items || items.length === 0"
class="core-horizontal-scroll"
(scroll)="scrollControls.updateScrollPosition()"
>
<core-loading [hideUntil]="loaded">
<div [id]="scrollElementId" [hidden]="!items || items.length === 0" class="core-horizontal-scroll"
(scroll)="scrollControls.updateScrollPosition()">
<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-item class="core-course-module-handler item-media ion-text-wrap" detail="false" (click)="action($event, item)"
button>
<img slot="start" [src]="item.iconUrl" alt="" role="presentation" *ngIf="item.iconUrl" class="core-module-icon">
<ion-item class="core-course-module-handler ion-text-wrap" detail="false" (click)="action($event, item)" button>
<core-mod-icon slot="start" *ngIf="item.iconUrl" [modicon]="item.iconUrl" [modname]="item.modname"
[componentId]="item.cmid" [showAlt]="false">
</core-mod-icon>
<ion-label>
<!-- Add the icon title so accessibility tools read it. -->
<span class="sr-only" *ngIf="item.iconTitle">{{ item.iconTitle }}</span>
@ -33,10 +33,11 @@
</ion-item>
</ion-card>
</div>
<div class="safe-area-pseudo-padding-end"></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>
</core-loading>

View File

@ -1,12 +1,23 @@
@import "~theme/globals";
:host {
.core-horizontal-scroll > div > div {
.core-horizontal-scroll div.core-horizontal-scroll-item {
@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 {
--inner-border-width: 0;
--inner-border-width: 0px;
}
core-loading {
--loading-inline-min-height: 102px;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,41 +1,25 @@
<ion-item-divider sticky="true">
<ion-label>
<h2>{{ 'addon.block_starredcourses.pluginname' | translate }}</h2>
<h2>{{ 'addon.block_starredcourses.pluginname' | translate }}</h2>
</ion-label>
<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>
</div>
</ion-item-divider>
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="safe-area-page margin">
<core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg" inline="true"
<core-loading [hideUntil]="loaded">
<core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg"
[message]="'addon.block_starredcourses.nocourses' | translate"></core-empty-box>
<!-- List of courses. -->
<div
[hidden]="courses.length === 0"
[id]="scrollElementId"
class="core-horizontal-scroll"
(scroll)="scrollControls.updateScrollPosition()"
>
<div [hidden]="courses.length === 0" [id]="scrollElementId" class="core-horizontal-scroll"
(scroll)="scrollControls.updateScrollPosition()">
<div (onResize)="scrollControls.updateScrollPosition()" class="flex-row">
<div class="safe-area-pseudo-padding-start"></div>
<ng-container *ngFor="let course of courses">
<core-courses-course-progress [course]="course" class="core-block_starredcourses"
[showDownload]="downloadCourseEnabled && downloadEnabled"></core-courses-course-progress>
<core-courses-course-list-item [course]="course" class="core-block_starredcourses" layout="summarycard">
</core-courses-course-list-item>
</ng-container>
<div class="safe-area-pseudo-padding-end"></div>
</div>
</div>
</core-loading>

View File

@ -12,17 +12,20 @@
// See the License for the specific language governing permissions and
// 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 { CoreSites } from '@services/sites';
import { CoreCoursesProvider, CoreCoursesMyCoursesUpdatedEventData, CoreCourses } from '@features/courses/services/courses';
import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper';
import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course-helper';
import {
CoreCourseSearchedDataWithExtraInfoAndOptions,
CoreEnrolledCourseDataWithOptions,
} from '@features/courses/services/courses-helper';
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 { 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.
@ -31,36 +34,25 @@ import { CoreDomUtils } from '@services/utils/dom';
selector: 'addon-block-starredcourses',
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;
protected prefetchIconsInitialized = false;
protected site: CoreSite;
protected isDestroyed = false;
protected coursesObserver?: CoreEventObserver;
protected updateSiteObserver?: CoreEventObserver;
protected courseIds: number[] = [];
protected fetchContentDefaultError = 'Error getting starred courses data.';
constructor() {
super('AddonBlockStarredCoursesComponent');
this.site = CoreSites.getRequiredCurrentSite();
}
/**
* Component being initialized.
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
// Generate unique id for scroll element.
@ -68,25 +60,10 @@ export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent im
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(
CoreCoursesProvider.EVENT_MY_COURSES_UPDATED,
(data) => {
if (this.shouldRefreshOnUpdatedEvent(data)) {
this.refreshCourseList();
}
this.refreshContent();
this.refreshCourseList(data);
},
CoreSites.getCurrentSiteId(),
@ -96,128 +73,130 @@ export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent im
}
/**
* Detect changes on input properties.
*/
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.
* @inheritdoc
*/
protected async invalidateContent(): Promise<void> {
const promises: Promise<void>[] = [];
const courseIds = this.courses.map((course) => course.id);
promises.push(CoreCourses.invalidateUserCourses().finally(() =>
// 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;
});
await this.invalidateCourses(courseIds);
}
/**
* Fetch the courses.
* Invalidate list of courses.
*
* @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> {
const showCategories = this.block.configsRecord && this.block.configsRecord.displaycategories &&
this.block.configsRecord.displaycategories.value == '1';
this.courses = await CoreCoursesHelper.getUserCoursesWithOptions('timemodified', 0, 'isfavourite', showCategories);
this.initPrefetchCoursesIcons();
// Timemodified not present, use the block WS to retrieve the info.
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.
*
* @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.
* Refresh course list based on a EVENT_MY_COURSES_UPDATED event.
*
* @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) {
// Always update if user enrolled in a course.
// 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) {
// Update list when making a course favourite or not.
return true;
}
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 courseIndex = this.courses.findIndex((course) => course.id == data.courseId);
if (courseIndex < 0) {
// Not found, use WS update. Usually new favourite.
return await this.refreshContent();
}
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 {
this.isDestroyed = true;
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 {
.tag_cloud {
font-size: 80%;
text-align: center;
ul.inline-list {
list-style: none;
margin-left: 0;
margin-right: 0;
margin: 0;
-webkit-padding-start: 0;
li {
@ -13,8 +11,8 @@
display: inline-block;
a {
background: var(--ion-color-primary);
color: var(--ion-color-primary-contrast);
background: var(--primary);
color: var(--primary-contrast);
padding: 3px 8px;
-webkit-font-smoothing: antialiased;
display: inline-block;
@ -26,7 +24,7 @@
contain: content;
vertical-align: baseline;
text-decoration: none;
border-radius: 4px;
border-radius: var(--small-radius);
}
.s20 {
font-size: 2.7em;

View File

@ -15,11 +15,10 @@
import { NgModule } from '@angular/core';
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 { AddonBlockTimelineEventsComponent } from './events/events';
import { CoreSearchComponentsModule } from '@features/search/components/components.module';
@NgModule({
declarations: [
@ -28,8 +27,7 @@ import { AddonBlockTimelineEventsComponent } from './events/events';
],
imports: [
CoreSharedModule,
CoreCoursesComponentsModule,
CoreCourseComponentsModule,
CoreSearchComponentsModule,
],
exports: [
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-divider [color]="dayEvents.color">
<ion-label><h3>{{ dayEvents.dayTimestamp * 1000 | coreFormatDate:"strftimedayshort" }}</h3></ion-label>
</ion-item-divider>
<ion-item>
<ion-label>
<h4 [class.core-bold]="!course">{{ dayEvents.dayTimestamp * 1000 | coreFormatDate:"strftimedaydate" }}</h4>
</ion-label>
</ion-item>
<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)"
[attr.aria-label]="event.name" button>
<img slot="start" [src]="event.iconUrl" alt="" role="presentation" *ngIf="event.iconUrl" class="core-module-icon">
<ion-item class="addon-block-timeline-activity" detail="false" (click)="action($event, event.url)" [attr.aria-label]="event.name"
button lines="full">
<ion-label>
<!-- Add the icon title so accessibility tools read it. -->
<span class="sr-only" *ngIf="event.iconTitle">{{ event.iconTitle }}</span>
<p class="item-heading">
<core-format-text [text]="event.name" contextLevel="module" [contextInstanceId]="event.id"
[courseId]="event.course && event.course.id">
</core-format-text>
</p>
<p *ngIf="showCourse && event.course">
<core-format-text [text]="event.course.fullnamedisplay" contextLevel="course"
[contextInstanceId]="event.course.id">
</core-format-text>
</p>
<ion-button fill="clear" class="ion-hide-md-up ion-text-wrap" (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>
<ion-row class="ion-justify-content-between ion-align-items-center ion-no-padding">
<ion-col class="addon-block-timeline-activity-main ion-no-padding">
<ion-row class="ion-justify-content-between ion-align-items-center ion-nowrap ion-no-padding">
<ion-col class="addon-block-timeline-activity-time ion-no-padding">
<small>{{event.timesort * 1000 | coreFormatDate:"strftimetime24" }}</small>
<core-mod-icon *ngIf="event.iconUrl" [modicon]="event.iconUrl" [componentId]="event.instance"
[modname]="event.modulename">
</core-mod-icon>
</ion-col>
<ion-col class="addon-block-timeline-activity-name ion-no-padding">
<p class="item-heading addon-block-timeline-activity-name-with-status">
<span>
<core-format-text [text]="event.activityname || event.name" contextLevel="module"
[contextInstanceId]="event.id" [courseId]="event.course && event.course.id">
</core-format-text>
</span>
<ion-badge *ngIf="event.overdue" color="danger">{{ 'addon.block_timeline.overdue' | translate }}
</ion-badge>
</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>
<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>
</ng-container>
</ion-item-group>
<div class="ion-padding ion-text-center" *ngIf="canLoadMore && !empty">
<!-- 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 }}
</ion-button>
<ion-spinner *ngIf="loadingMore" [attr.aria-label]="'core.loading' | translate"></ion-spinner>
</div>
<core-empty-box *ngIf="empty" image="assets/img/icons/activities.svg" [message]="'addon.block_timeline.noevents' | translate"
inline="true">
<ion-item *ngIf="empty && course">
<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>

View File

@ -1,6 +1,81 @@
.events-info {
display: flex;
flex-direction: column;
text-align: end;
padding: 10px 0;
@import "~theme/globals";
h3 {
font-weight: bold;
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 { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUtils } from '@services/utils/utils';
import { CoreCourse } from '@features/course/services/course';
import moment from 'moment';
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
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.
@ -34,34 +34,33 @@ import { AddonCalendarEvent } from '@addons/calendar/services/calendar';
export class AddonBlockTimelineEventsComponent implements OnChanges {
@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() 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.
@Output() loadMore: EventEmitter<void>; // Notify that more events should be loaded.
@Input() overdue = false; // If filtering overdue events or not.
@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;
loadingMore = false;
filteredEvents: AddonBlockTimelineEventFilteredEvent[] = [];
constructor() {
this.loadMore = new EventEmitter();
}
/**
* Detect changes on input properties.
* @inheritdoc
*/
ngOnChanges(changes: {[name: string]: SimpleChange}): void {
this.showCourse = CoreUtils.isTrueOrOne(this.showCourse);
async ngOnChanges(changes: {[name: string]: SimpleChange}): Promise<void> {
this.showCourse = !this.course;
if (changes.events || changes.from || changes.to) {
if (this.events && this.events.length > 0) {
const filteredEvents = this.filterEventsByTime(this.from, this.to);
if (this.events) {
const filteredEvents = await this.filterEventsByTime();
this.empty = !filteredEvents || filteredEvents.length <= 0;
const eventsByDay: Record<number, AddonCalendarEvent[]> = {};
const eventsByDay: Record<number, AddonBlockTimelineEvent[]> = {};
filteredEvents.forEach((event) => {
const dayTimestamp = CoreTimeUtils.getMidnightForTimestamp(event.timesort);
if (eventsByDay[dayTimestamp]) {
eventsByDay[dayTimestamp].push(event);
} else {
@ -69,16 +68,15 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
}
});
const todaysMidnight = CoreTimeUtils.getMidnightForTimestamp();
this.filteredEvents = [];
Object.keys(eventsByDay).forEach((key) => {
this.filteredEvents = Object.keys(eventsByDay).map((key) => {
const dayTimestamp = parseInt(key);
this.filteredEvents.push({
color: dayTimestamp < todaysMidnight ? 'danger' : 'light',
return {
dayTimestamp,
events: eventsByDay[dayTimestamp],
});
};
});
this.loadingMore = false;
} else {
this.empty = true;
}
@ -88,26 +86,41 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
/**
* 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.
*/
protected filterEventsByTime(start: number, end?: number): AddonBlockTimelineEvent[] {
start = moment().add(start, 'days').startOf('day').unix();
end = typeof end != 'undefined' ? moment().add(end, 'days').startOf('day').unix() : end;
protected async filterEventsByTime(): Promise<AddonBlockTimelineEvent[]> {
const start = AddonBlockTimeline.getDayStart(this.from);
const end = this.to !== undefined
? AddonBlockTimeline.getDayStart(this.to)
: undefined;
return this.events.filter((event) => {
if (end) {
return start <= event.timesort && event.timesort < end;
const now = CoreTimeUtils.timestamp();
const midnight = AddonBlockTimeline.getDayStart();
return await Promise.all(this.events.filter((event) => {
if (start > event.timesort || (end && event.timesort >= end)) {
return false;
}
return start <= event.timesort;
}).map((event) => {
event.iconUrl = CoreCourse.getModuleIconSrc(event.icon.component);
event.iconTitle = event.modulename && CoreCourse.translateModuleName(event.modulename);
// Already calculated on 4.0 onwards but this will be live.
event.overdue = event.timesort < now;
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;
});
}));
}
/**
@ -121,12 +134,12 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
/**
* Action clicked.
*
* @param e Click event.
* @param event Click event.
* @param url Url of the action.
*/
async action(e: Event, url: string): Promise<void> {
e.preventDefault();
e.stopPropagation();
async action(event: Event, url: string): Promise<void> {
event.preventDefault();
event.stopPropagation();
// Fix URL format.
url = CoreTextUtils.decodeHTMLEntities(url);
@ -136,7 +149,7 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
try {
const treated = await CoreContentLinksHelper.handleLink(url);
if (!treated) {
return CoreSites.getCurrentSite()?.openInBrowserWithAutoLoginIfSameSite(url);
return CoreSites.getRequiredCurrentSite().openInBrowserWithAutoLoginIfSameSite(url);
}
} finally {
modal.dismiss();
@ -145,7 +158,8 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
}
type AddonBlockTimelineEvent = AddonCalendarEvent & {
type AddonBlockTimelineEvent = Omit<AddonCalendarEvent, 'eventtype'> & {
eventtype: string;
iconUrl?: string;
iconTitle?: string;
};
@ -153,5 +167,4 @@ type AddonBlockTimelineEvent = AddonCalendarEvent & {
type AddonBlockTimelineEventFilteredEvent = {
events: AddonBlockTimelineEvent[];
dayTimestamp: number;
color: string;
};

View File

@ -1,57 +1,73 @@
<ion-item-divider sticky="true">
<ion-label><h2>{{ 'addon.block_timeline.pluginname' | translate }}</h2></ion-label>
<core-context-menu slot="end">
<core-context-menu-item *ngIf="loaded" [priority]="900" [content]="'addon.block_timeline.sortbydates' | translate"
(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-label>
<h2>{{ 'addon.block_timeline.pluginname' | translate }}</h2>
</ion-label>
</ion-item-divider>
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="margin">
<div class="safe-padding-horizontal">
<core-combobox [selection]="filter" (onChange)="switchFilter($event)">
<ion-select-option class="ion-text-wrap" value="all">
{{ 'core.all' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="overdue">
{{ 'addon.block_timeline.overdue' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" disabled value="disabled">
{{ 'addon.block_timeline.duedate' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="next7days">
{{ 'addon.block_timeline.next7days' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="next30days">
{{ 'addon.block_timeline.next30days' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="next3months">
{{ '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>
</div>
<core-loading [hideUntil]="timeline.loaded" [hidden]="sort != 'sortbydates'" [fullscreen]="false" class="margin">
<addon-block-timeline-events [events]="timeline.events" showCourse="true" [canLoadMore]="timeline.canLoadMore"
(loadMore)="loadMoreTimeline()" [from]="dataFrom" [to]="dataTo"></addon-block-timeline-events>
<core-loading [hideUntil]="loaded">
<ion-row class="ion-hide-md-up addon-block-timeline-filter" *ngIf="searchEnabled">
<ion-col>
<!-- 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-row>
<ion-row class="ion-justify-content-between ion-align-items-center addon-block-timeline-filter">
<ion-col size="auto">
<core-combobox [selection]="filter" (onChange)="switchFilter($event)">
<ion-select-option class="ion-text-wrap" value="all">
{{ 'core.all' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="overdue">
{{ 'addon.block_timeline.overdue' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap core-select-option-title" disabled value="disabled">
{{ 'addon.block_timeline.duedate' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="next7days">
{{ 'addon.block_timeline.next7days' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="next30days">
{{ 'addon.block_timeline.next30days' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="next3months">
{{ '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 [hideUntil]="timelineCourses.loaded" [hidden]="sort != 'sortbycourses'"
[fullscreen]="false" class="safe-area-page margin">
<ion-grid class="ion-no-padding">
<ion-row class="ion-no-padding">
<ion-col *ngFor="let course of timelineCourses.courses" class="ion-no-padding" size="12" size-md="6">
<core-courses-course-progress [course]="course">
<addon-block-timeline-events [events]="course.events" [canLoadMore]="course.canLoadMore"
(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 [hideUntil]="timelineCourses.loaded" [hidden]="sort != 'sortbycourses'">
<ng-container *ngFor="let course of timelineCourses.courses">
<addon-block-timeline-events [events]="course.events" [canLoadMore]="course.canLoadMore" (loadMore)="loadMore(course)"
[course]="course" [from]="dataFrom" [to]="dataTo" [overdue]="overdue"></addon-block-timeline-events>
</ng-container>
<core-empty-box *ngIf="timelineCourses.courses.length == 0" image="assets/img/icons/courses.svg"
[message]="'addon.block_timeline.noevents' | translate"></core-empty-box>
</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.
import { Component, OnInit } from '@angular/core';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreSites } from '@services/sites';
import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component';
import { AddonBlockTimeline } from '../../services/timeline';
@ -24,6 +23,7 @@ import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/
import { CoreSite } from '@classes/site';
import { CoreCourses } from '@features/courses/services/courses';
import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate';
import { CoreNavigator } from '@services/navigator';
/**
* Component to render a timeline block.
@ -31,12 +31,13 @@ import { CoreCourseOptionsDelegate } from '@features/course/services/course-opti
@Component({
selector: 'addon-block-timeline',
templateUrl: 'addon-block-timeline.html',
styleUrls: ['timeline.scss'],
})
export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implements OnInit {
sort = 'sortbydates';
filter = 'next30days';
currentSite?: CoreSite;
currentSite!: CoreSite;
timeline: {
events: AddonCalendarEvent[];
loaded: boolean;
@ -57,24 +58,40 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
dataFrom?: number;
dataTo?: number;
overdue = false;
protected courseIds: number[] = [];
searchEnabled = false;
searchText = '';
protected courseIdsToInvalidate: number[] = [];
protected fetchContentDefaultError = 'Error getting timeline data.';
protected gradePeriodAfter = 0;
protected gradePeriodBefore = 0;
constructor() {
super('AddonBlockTimelineComponent');
}
/**
* Component being initialized.
* @inheritdoc
*/
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.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();
}
@ -91,8 +108,8 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
promises.push(AddonBlockTimeline.invalidateActionEventsByCourses());
promises.push(CoreCourses.invalidateUserCourses());
promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions());
if (this.courseIds.length > 0) {
promises.push(CoreCourses.invalidateCoursesByField('ids', this.courseIds.join(',')));
if (this.courseIdsToInvalidate.length > 0) {
promises.push(CoreCourses.invalidateCoursesByField('ids', this.courseIdsToInvalidate.join(',')));
}
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.
*
* @param course Course.
* @param course Course. If defined, it will update the course events, timeline otherwise.
* @return Promise resolved when done.
*/
async loadMoreCourse(course: AddonBlockTimelineCourse): Promise<void> {
async loadMore(course?: AddonBlockTimelineCourse): Promise<void> {
try {
const courseEvents = await AddonBlockTimeline.getActionEventsByCourse(course.id, course.canLoadMore);
course.events = course.events?.concat(courseEvents.events);
course.canLoadMore = courseEvents.canLoadMore;
if (course) {
const courseEvents =
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) {
CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError);
}
@ -151,9 +162,9 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
* @return Promise resolved when done.
*/
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;
}
@ -163,20 +174,36 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
* @return Promise resolved when done.
*/
protected async fetchMyOverviewTimelineByCourses(): Promise<void> {
const courses = await CoreCoursesHelper.getUserCoursesWithOptions();
const today = CoreTimeUtils.timestamp();
try {
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) =>
(course.startdate || 0) <= today && (!course.enddate || course.enddate >= today));
// Do not filter courses by date because they can contain activities due.
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) {
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.canLoadMore = courseEvents[course.id].canLoadMore;
return true;
});
}
}
@ -188,12 +215,13 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
*/
switchFilter(filter: string): void {
this.filter = filter;
this.currentSite?.setLocalSiteConfig('AddonBlockTimelineFilter', this.filter);
this.currentSite.setLocalSiteConfig('AddonBlockTimelineFilter', this.filter);
this.overdue = this.filter === 'overdue';
switch (this.filter) {
case 'overdue':
this.dataFrom = -14;
this.dataTo = 0;
this.dataTo = 1;
break;
case 'next7days':
this.dataFrom = 0;
@ -226,7 +254,7 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
*/
switchSort(sort: string): void {
this.sort = sort;
this.currentSite?.setLocalSiteConfig('AddonBlockTimelineSort', this.sort);
this.currentSite.setLocalSiteConfig('AddonBlockTimelineSort', this.sort);
if (!this.timeline.loaded && this.sort == 'sortbydates') {
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[];
canLoadMore?: number;
};

View File

@ -5,9 +5,10 @@
"next6months": "Next 6 months",
"next7days": "Next 7 days",
"nocoursesinprogress": "No in-progress courses",
"noevents": "No upcoming activities due",
"noevents": "No activities require action",
"overdue": "Overdue",
"pluginname": "Timeline",
"searchevents": "Search by activity type or name",
"sortbycourses": "Sort by courses",
"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 { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
import { makeSingleton } from '@singletons';
import { AddonBlockTimeline } from './timeline';
import { CoreCoursesDashboard } from '@features/courses/services/dashboard';
/**
* Block handler.
@ -36,7 +36,7 @@ export class AddonBlockTimelineHandlerService extends CoreBlockBaseHandler {
* @return Whether or not the handler is enabled on a site level.
*/
async isEnabled(): Promise<boolean> {
const enabled = await AddonBlockTimeline.isAvailable();
const enabled = !CoreCoursesDashboard.isDisabledInSite();
const currentSite = CoreSites.getCurrentSite();
return enabled && ((currentSite && currentSite.isVersionGreaterEqualThan('3.6')) ||

View File

@ -14,7 +14,6 @@
import { Injectable } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreCoursesDashboard } from '@features/courses/services/dashboard';
import {
AddonCalendarEvents,
AddonCalendarEventsGroupedByCourse,
@ -26,7 +25,6 @@ import {
import moment from 'moment';
import { makeSingleton } from '@singletons';
import { CoreSiteWSPreSets } from '@classes/site';
import { CoreError } from '@classes/errors/error';
// Cache key was maintained from block myoverview when blocks were splitted.
const ROOT_CACHE_KEY = 'myoverview:';
@ -45,17 +43,19 @@ export class AddonBlockTimelineProvider {
*
* @param courseId Only events in this course.
* @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.
* @return Promise resolved when the info is retrieved.
*/
async getActionEventsByCourse(
courseId: number,
afterEventId?: number,
searchValue = '',
siteId?: string,
): Promise<{ events: AddonCalendarEvent[]; canLoadMore?: number }> {
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 = {
timesortfrom: time,
@ -70,17 +70,18 @@ export class AddonBlockTimelineProvider {
cacheKey: this.getActionEventsByCourseCacheKey(courseId),
};
if (searchValue != '') {
data.searchvalue = searchValue;
preSets.getFromCache = false;
}
const courseEvents = await site.read<AddonCalendarEvents>(
'core_calendar_get_action_events_by_course',
data,
preSets,
);
if (courseEvents && courseEvents.events) {
return this.treatCourseEvents(courseEvents, time);
}
throw new CoreError('No events returned on core_calendar_get_action_events_by_course.');
return this.treatCourseEvents(courseEvents, time);
}
/**
@ -98,15 +99,17 @@ export class AddonBlockTimelineProvider {
*
* @param courseIds Course IDs.
* @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.
*/
async getActionEventsByCourses(
courseIds: number[],
searchValue = '',
siteId?: string,
): Promise<{[courseId: string]: { events: AddonCalendarEvent[]; canLoadMore?: number } }> {
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 = {
timesortfrom: time,
@ -117,6 +120,11 @@ export class AddonBlockTimelineProvider {
cacheKey: this.getActionEventsByCoursesCacheKey(),
};
if (searchValue != '') {
data.searchvalue = searchValue;
preSets.getFromCache = false;
}
const events = await site.read<AddonCalendarEventsGroupedByCourse>(
'core_calendar_get_action_events_by_courses',
data,
@ -145,16 +153,18 @@ export class AddonBlockTimelineProvider {
* Get calendar action events based on the timesort value.
*
* @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.
* @return Promise resolved when the info is retrieved.
*/
async getActionEventsByTimesort(
afterEventId?: number,
searchValue = '',
siteId?: string,
): Promise<{ events: AddonCalendarEvent[]; canLoadMore?: number }> {
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 data: AddonCalendarGetActionEventsByTimesortWSParams = {
@ -171,25 +181,27 @@ export class AddonBlockTimelineProvider {
uniqueCacheKey: true,
};
if (searchValue != '') {
data.searchvalue = searchValue;
preSets.getFromCache = false;
preSets.cacheKey += ':' + searchValue;
}
const result = await site.read<AddonCalendarEvents>(
'core_calendar_get_action_events_by_timesort',
data,
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.
const events = result.events.filter((element) => element.timesort >= timesortfrom);
// Filter events by time in case it uses cache.
const events = result.events.filter((element) => element.timesort >= timesortfrom);
return {
events,
canLoadMore,
};
}
throw new CoreError('No events returned on core_calendar_get_action_events_by_timesort.');
return {
events,
canLoadMore,
};
}
/**
@ -239,24 +251,6 @@ export class AddonBlockTimelineProvider {
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.
*
@ -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);

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ import { ContextLevel } from '@/core/constants';
import { AddonBlog, AddonBlogFilter, AddonBlogPost, AddonBlogProvider } from '@addons/blog/services/blog';
import { Component, OnInit } from '@angular/core';
import { CoreComments } from '@features/comments/services/comments';
import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager';
import { CoreTag } from '@features/tag/services/tag';
import { CoreUser, CoreUserProfile } from '@features/user/services/user';
import { IonRefresher } from '@ionic/angular';
@ -42,6 +43,7 @@ export class AddonBlogEntriesPage implements OnInit {
protected canLoadMoreEntries = false;
protected canLoadMoreUserEntries = true;
protected siteHomeId: number;
protected fetchSuccess = false;
loaded = false;
canLoadMore = false;
@ -118,9 +120,10 @@ export class AddonBlogEntriesPage implements OnInit {
this.commentsEnabled = !CoreComments.areCommentsDisabledInSite();
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.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 = user;
return;
}).catch(() => {
// Ignore errors.
});
entry.user = await CoreUtils.ignoreErrors(CoreUser.getProfile(entry.userid, entry.courseid, true));
});
if (refresh) {
@ -201,6 +198,11 @@ export class AddonBlogEntriesPage implements OnInit {
}
await Promise.all(promises);
if (!this.fetchSuccess) {
this.fetchSuccess = true;
CoreUtils.ignoreErrors(AddonBlog.logView(this.filter));
}
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.blog.errorloadentries', true);
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.
* @return Promise resolved with true if enabled, resolved with false or rejected otherwise.
* @since 3.6
*/
async isPluginEnabled(siteId?: string): Promise<boolean> {
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> {
const enabled = await CoreCourseHelper.hasABlockNamed(courseId, 'blog_menu');
if (enabled && navOptions && typeof navOptions.blogs != 'undefined') {
if (enabled && navOptions && navOptions.blogs !== undefined) {
return navOptions.blogs;
}

View File

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

View File

@ -13,8 +13,14 @@
// limitations under the License.
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 { CoreSites } from '@services/sites';
import { makeSingleton } from '@singletons';
import { AddonBlog } from '../blog';
@ -24,8 +30,8 @@ import { AddonBlog } from '../blog';
@Injectable({ providedIn: 'root' })
export class AddonBlogUserHandlerService implements CoreUserProfileHandler {
name = 'AddonBlog:blogs';
priority = 300;
name = 'AddonBlog'; // This name doesn't match any disabled feature, they'll be checked in isEnabledForContext.
priority = 200;
type = CoreUserDelegateService.TYPE_NEW_PAGE;
/**
@ -35,6 +41,27 @@ export class AddonBlogUserHandlerService implements CoreUserProfileHandler {
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
*/
@ -43,11 +70,11 @@ export class AddonBlogUserHandlerService implements CoreUserProfileHandler {
icon: 'far-newspaper',
title: 'addon.blog.blogentries',
class: 'addon-blog-handler',
action: (event, user, courseId): void => {
action: (event, user, context, contextId): void => {
event.preventDefault();
event.stopPropagation();
CoreNavigator.navigateToSitePath('/blog', {
params: { courseId, userId: user.id },
params: { courseId: contextId, userId: user.id },
});
},
};

View File

@ -1,28 +1,27 @@
@import "~theme/globals";
:host {
--addon-calendar-blank-day-background-color: var(--gray-lighter);
--addon-calendar-blank-day-background-color: var(--light);
.item.addon-calendar-event {
> ion-icon {
color: white;
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 {
background-color: var(--addon-calendar-event-category-color);
}
&.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);
@each $category, $value in $calendar-event-category-colors {
&.addon-calendar-eventtype-#{$category} > ion-icon {
background-color: $value;
}
}
}
}

View File

@ -13,22 +13,11 @@
// limitations under the License.
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 { 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 {
return [
{
@ -36,27 +25,27 @@ function buildRoutes(injector: Injector): Routes {
data: {
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',
data: {
mainMenuTabRoot: AddonCalendarMainMenuHandlerService.PAGE_NAME,
},
loadChildren: () => import('@/addons/calendar/pages/list/list.module').then(m => m.AddonCalendarListPageModule),
},
{
path: 'settings',
path: 'calendar-settings',
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',
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, {
redirectTo: 'index',
pathMatch: 'full',

View File

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

View File

@ -1,112 +1,110 @@
<!-- Add buttons to the nav bar. -->
<core-navbar-buttons slot="end" prepend>
<core-context-menu>
<core-context-menu-item *ngIf="canNavigate && !isCurrentMonth && displayNavButtons" [priority]="900"
[content]="'addon.calendar.currentmonth' | translate" iconAction="fas-calendar-day"
(action)="goToCurrentMonth()"></core-context-menu-item>
<core-context-menu-item *ngIf="canNavigate && !selectedMonthIsCurrent() && displayNavButtons" [priority]="900"
[content]="'addon.calendar.currentmonth' | translate" iconAction="fas-calendar-day" (action)="goToCurrentMonth()">
</core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
<core-loading [hideUntil]="loaded" class="safe-area-page">
<!-- Period name and arrows to navigate. -->
<ion-grid class="ion-no-padding addon-calendar-navigation">
<ion-row class="ion-align-items-center">
<ion-col class="ion-text-start" *ngIf="canNavigate">
<ion-button fill="clear" (click)="loadPrevious()" [attr.aria-label]="'core.previous' | translate">
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-col>
<ion-col class="ion-text-center addon-calendar-period">
<h2 id="addon-calendar-monthname">{{ periodName }}</h2>
</ion-col>
<ion-col class="ion-text-end" *ngIf="canNavigate">
<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>
</ion-button>
</ion-col>
</ion-row>
</ion-grid>
<!-- Calendar view. -->
<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>
<core-loading [hideUntil]="loaded">
<div class="core-swipe-slides-container">
<!-- Period name and arrows to navigate. -->
<ion-grid class="ion-no-padding addon-calendar-navigation">
<ion-row class="ion-align-items-center">
<ion-col class="ion-text-start" *ngIf="canNavigate">
<ion-button fill="clear" (click)="loadPrevious()" [attr.aria-label]="'core.previous' | translate">
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-col>
<ion-col class="ion-text-center addon-calendar-period">
<h2 id="addon-calendar-monthname">
{{ periodName }}
<ion-spinner *ngIf="!selectedMonthLoaded()" class="addon-calendar-loading-month">
</ion-spinner>
</h2>
</ion-col>
<ion-col class="ion-text-end" *ngIf="canNavigate">
<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>
</ion-button>
</ion-col>
</ion-row>
</div>
<div role="rowgroup">
</ion-grid>
<!-- Weeks. -->
<ion-row *ngFor="let week of weeks" class="addon-calendar-week" role="row">
<!-- Empty slots (first week). -->
<ion-col *ngFor="let value of week.prepadding" class="dayblank addon-calendar-day" role="cell"></ion-col>
<ion-col
*ngFor="let day of week.days"
class="addon-calendar-day ion-text-center"
[ngClass]='{
"hasevents": day.hasevents,
"today": isCurrentMonth && day.istoday,
"weekend": day.isweekend,
"duration_finish": day.haslastdayofevent
}'
[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>
<core-swipe-slides [manager]="manager">
<ng-template let-month="item">
<!-- Calendar view. -->
<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 month.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-row>
</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>
<div role="rowgroup">
<!-- Weeks. -->
<ion-row *ngFor="let week of month.weeks" class="addon-calendar-week" role="row">
<!-- Empty slots (first week). -->
<ion-col *ngFor="let value of week.prepadding" class="dayblank addon-calendar-day" role="cell">
</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>

View File

@ -1,5 +1,12 @@
@import "~theme/globals";
: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 {
padding-top: 5px;
@ -10,22 +17,22 @@
.addon-calendar-months {
background-color: var(--contrast-background);
padding: 0;
font-size: 14px;
font-size: var(--text-size);
}
.addon-calendar-day {
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;
min-height: 60px;
cursor: pointer;
&:first-child {
padding-left: 10px;
@include padding-horizontal(10px, null);
}
&:last-child {
border-right: 0;
padding-left: 8px;
@include border-end(0);
@include padding-horizontal(8px, null);
}
&.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 {
text-align: start;
}
@ -56,7 +63,7 @@
&.today .addon-calendar-day-number span {
border: 2px solid var(--addon-calendar-today-border-color);
line-height: 20px;;
line-height: 20px;
border-radius: 50%;
}
&.dayblank {
@ -82,9 +89,7 @@
}
.addon-calendar-day-more {
margin-top: 0.6em;
margin-bottom: 0.6em;
margin-right: 4px;
@include margin(0.6em, null, 0.6em, 4px);
}
.addon-calendar-dot-types {
@ -98,6 +103,10 @@
margin-top: 10px;
font-size: 1.2rem;
}
.addon-calendar-loading-month {
height: 20px;
}
}
.addon-calendar-weekday {
@ -106,10 +115,10 @@
}
.addon-calendar-day-events {
text-align: left;
text-align: start;
ion-icon {
margin-right: 2px;
@include margin-horizontal(null, 2px);
font-size: 1em;
}
}
@ -127,66 +136,22 @@
margin-right: 1px;
margin-left: 1px;
&.calendar_event_category {
background-color: var(--addon-calendar-event-category-color);
}
&.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);
@each $category, $value in $calendar-event-category-colors {
&.calendar_event_#{$category} {
background-color: $value;
}
}
}
.core-module-icon {
margin-right: 1px;
margin-left: 1px;
--size: 16px;
display: inline-block;
vertical-align: bottom;
}
.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;
}
ion-slide {
display: block;
font-size: inherit;
justify-content: start;
align-items: start;
text-align: start;
}
}
: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,
KeyValueDiffers,
KeyValueDiffer,
ViewChild,
HostBinding,
} from '@angular/core';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites';
@ -40,7 +42,13 @@ import { AddonCalendarFilter, AddonCalendarHelper } from '../../services/calenda
import { AddonCalendarOffline } from '../../services/calendar-offline';
import { CoreCategoryData, CoreCourses } from '@features/courses/services/courses';
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.
@ -52,54 +60,31 @@ import { CoreLocalNotifications } from '@services/local-notifications';
})
export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestroy {
@ViewChild(CoreSwipeSlidesComponent) slides?: CoreSwipeSlidesComponent<PreloadedMonth>;
@Input() initialYear?: number; // Initial year to load.
@Input() initialMonth?: number; // Initial month to load.
@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() displayNavButtons?: string | boolean; // Whether to display nav buttons created by this component. Defaults to true.
@Output() onEventClicked = new EventEmitter<number>();
@Output() onDayClicked = new EventEmitter<{day: number; month: number; year: number}>();
periodName?: string;
weekDays: AddonCalendarWeekDaysTranslationKeys[] = [];
weeks: AddonCalendarWeek[] = [];
manager?: CoreSwipeSlidesDynamicItemsManager<PreloadedMonth, AddonCalendarMonthSlidesItemsManagerSource>;
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 offlineEvents: { [monthId: string]: { [day: number]: AddonCalendarEventToDisplay[] } } =
{}; // Offline events classified in month & day.
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 hiddenDiffer?: boolean; // To detect changes in the hidden input.
protected filterDiffer: KeyValueDiffer<unknown, unknown>; // To detect changes in the filters input.
// Observers and listeners.
protected undeleteEventObserver: CoreEventObserver;
protected obsDefaultTimeChange?: CoreEventObserver;
protected managerUnsubscribe?: () => void;
constructor(
differs: KeyValueDiffers,
) {
constructor(differs: KeyValueDiffers) {
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).
this.undeleteEventObserver = CoreEvents.on(
AddonCalendarProvider.UNDELETED_EVENT_EVENT,
@ -112,27 +97,40 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
this.undeleteEvent(data.eventId);
// 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) {
this.deletedEvents.splice(index, 1);
this.manager?.getSource().deletedEvents.splice(index, 1);
}
},
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.
*/
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();
this.month = this.initialMonth ? this.initialMonth : now.getMonth() + 1;
this.calculateIsCurrentMonth();
const source = new AddonCalendarMonthSlidesItemsManagerSource(this, moment({
year: this.initialYear,
month: this.initialMonth ? this.initialMonth - 1 : undefined,
}));
this.manager = new CoreSwipeSlidesDynamicItemsManager(source);
this.managerUnsubscribe = this.manager.addListener({
onSelectedItemUpdated: (item) => {
this.onMonthViewed(item);
},
});
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).
*/
ngDoCheck(): void {
this.canNavigate = typeof this.canNavigate == 'undefined' ? true : CoreUtils.isTrueOrOne(this.canNavigate);
this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true :
CoreUtils.isTrueOrOne(this.displayNavButtons);
const items = this.manager?.getSource().getItems();
if (this.weeks) {
if (items?.length) {
// 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) {
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.
*/
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 {
await Promise.all(promises);
await this.fetchEvents();
await this.manager?.getSource().fetchData();
await this.manager?.getSource().load(this.manager?.getSelectedItem());
} catch (error) {
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> {
// 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;
}
}
onMonthViewed(month: MonthBasicData): void {
// Calculate the period name. We don't use the one in result because it's in server's language.
this.periodName = CoreTimeUtils.userDate(
new Date(this.year!, this.month! - 1).getTime(),
month.moment.unix() * 1000,
'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.
* @return Promise resolved when done.
*/
async refreshData(afterChange?: boolean): Promise<void> {
const promises: Promise<void>[] = [];
async refreshData(afterChange = false): Promise<void> {
const selectedMonth = this.manager?.getSelectedItem() || null;
// Don't invalidate monthly events after a change, it has already been handled.
if (!afterChange) {
promises.push(AddonCalendar.invalidateMonthlyEvents(this.year!, this.month!));
if (afterChange) {
this.manager?.getSource().markAllItemsDirty();
}
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);
this.fetchData();
await this.fetchData();
}
/**
* Load next month.
*/
async loadNext(): Promise<void> {
this.increaseMonth();
this.loaded = false;
try {
await this.fetchEvents();
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
this.decreaseMonth();
}
this.loaded = true;
loadNext(): void {
this.slides?.slideNext();
}
/**
* Load previous month.
*/
async loadPrevious(): Promise<void> {
this.decreaseMonth();
this.loaded = false;
try {
await this.fetchEvents();
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
this.increaseMonth();
}
this.loaded = true;
loadPrevious(): void {
this.slides?.slidePrev();
}
/**
@ -386,106 +245,53 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
* @param day Day.
*/
dayClicked(day: number): void {
this.onDayClicked.emit({ day: day, month: this.month!, year: this.year! });
}
const selectedMonth = this.manager?.getSelectedItem();
if (!selectedMonth) {
return;
}
/**
* 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);
this.onDayClicked.emit({ day: day, month: selectedMonth.moment.month() + 1, year: selectedMonth.moment.year() });
}
/**
* Go to current month.
*/
async goToCurrentMonth(): Promise<void> {
const now = new Date();
const initialMonth = this.month;
const initialYear = this.year;
this.month = now.getMonth() + 1;
this.year = now.getFullYear();
const manager = this.manager;
const slides = this.slides;
if (!manager || !slides) {
return;
}
const currentMonth = {
moment: moment(),
};
this.loaded = false;
try {
await this.fetchEvents();
this.isCurrentMonth = true;
// Make sure the day is loaded.
await manager.getSource().loadItem(currentMonth);
slides.slideToItem(currentMonth);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
this.year = initialYear;
this.month = initialMonth;
}
this.loaded = true;
}
/**
* Decrease the current month.
*/
protected decreaseMonth(): void {
if (this.month === 1) {
this.month = 12;
this.year!--;
} else {
this.month!--;
} finally {
this.loaded = true;
}
}
/**
* Increase the current month.
* Check whether selected month is loaded.
*/
protected increaseMonth(): void {
if (this.month === 12) {
this.month = 1;
this.year!++;
} else {
this.month!++;
}
selectedMonthLoaded(): boolean {
return !!this.manager?.getSelectedItem()?.loaded;
}
/**
* Merge online events with the offline events of that period.
* Check whether selected month is current month.
*/
protected mergeEvents(): void {
const monthOfflineEvents: { [day: number]: AddonCalendarEventToDisplay[] } =
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]));
}
}
});
});
selectedMonthIsCurrent(): boolean {
return !!this.manager?.getSelectedItem()?.isCurrentMonth;
}
/**
@ -493,17 +299,293 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
*
* @param eventId Event ID.
*/
protected undeleteEvent(eventId: number): void {
if (!this.weeks) {
return;
}
protected async undeleteEvent(eventId: number): Promise<void> {
this.manager?.getSource().getItems()?.some((month) => {
if (!month.loaded) {
return false;
}
this.weeks.forEach((week) => {
week.days.forEach((day) => {
const event = day.eventsFormated!.find((event) => event.id == eventId);
return month.weeks?.some((week) => week.days.some((day) => {
const event = day.eventsFormated?.find((event) => event.id == eventId);
if (event) {
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.
*
* @param event Event object.
* @param currentTime Current time.
* @return True if it's in the past.
*/
protected isEventPast(event: { timestart: number; timeduration: number}): boolean {
return (event.timestart + event.timeduration) < this.currentTime!;
isEventPast(event: { timestart: number; timeduration: number}, currentTime: number): boolean {
return (event.timestart + event.timeduration) < currentTime;
}
/**
* Component destroyed.
* Invalidate content.
*
* @param selectedMonth The current selected month.
* @return Promise resolved when done.
*/
ngOnDestroy(): void {
this.undeleteEventObserver?.off();
this.obsDefaultTimeChange?.off();
async invalidateContent(selectedMonth: PreloadedMonth | null): Promise<void> {
const promises: Promise<void>[] = [];
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 { 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({
declarations: [
AddonCalendarCalendarComponent,
AddonCalendarUpcomingEventsComponent,
AddonCalendarFilterPopoverComponent,
AddonCalendarFilterComponent,
AddonCalendarReminderTimeModalComponent,
],
imports: [
CoreSharedModule,
@ -34,7 +36,8 @@ import { AddonCalendarFilterPopoverComponent } from './filter/filter';
exports: [
AddonCalendarCalendarComponent,
AddonCalendarUpcomingEventsComponent,
AddonCalendarFilterPopoverComponent,
AddonCalendarFilterComponent,
AddonCalendarReminderTimeModalComponent,
],
})
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 { CoreEnrolledCourseData } from '@features/courses/services/courses';
import { CoreUtils } from '@services/utils/utils';
import { ModalController } from '@singletons';
import { CoreEvents } from '@singletons/events';
import { AddonCalendarEventType, AddonCalendarProvider } from '../../services/calendar';
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({
selector: 'addon-calendar-filter-popover',
templateUrl: 'addon-calendar-filter-popover.html',
styleUrls: ['../../calendar-common.scss', 'filter-popover.scss'],
selector: 'addon-calendar-filter',
templateUrl: 'filter.html',
styleUrls: ['../../calendar-common.scss', 'filter.scss'],
})
export class AddonCalendarFilterPopoverComponent implements OnInit {
export class AddonCalendarFilterComponent implements OnInit {
@Input() filter: AddonCalendarFilter = {
filtered: false,
@ -56,7 +57,7 @@ export class AddonCalendarFilterPopoverComponent implements OnInit {
}
/**
* Init the component.
* @inheritdoc
*/
ngOnInit(): void {
this.courseId = this.filter.courseId || -1;
@ -80,4 +81,11 @@ export class AddonCalendarFilterPopoverComponent implements OnInit {
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