Merge pull request #3164 from NoelDeMartin/MOBILE-3153

MOBILE-3153: User Tours
main
Dani Palou 2022-03-14 16:30:01 +01:00 committed by GitHub
commit 1db5b85e40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 1534 additions and 48 deletions

View File

@ -139,7 +139,6 @@ const appConfig = {
'always', 'always',
], ],
'@typescript-eslint/type-annotation-spacing': 'error', '@typescript-eslint/type-annotation-spacing': 'error',
'@typescript-eslint/unified-signatures': 'error',
'header/header': [ 'header/header': [
2, 2,
'line', 'line',

View File

@ -257,6 +257,7 @@ function build_lang($lang, $keys) {
$text = str_replace('$a->', '$a.', $text); $text = str_replace('$a->', '$a.', $text);
$text = str_replace('{$a', '{{$a', $text); $text = str_replace('{$a', '{{$a', $text);
$text = str_replace('}', '}}', $text); $text = str_replace('}', '}}', $text);
$text = preg_replace('/@@.+?@@(<br>)?\\s*/', '', $text);
// Prevent double. // Prevent double.
$text = str_replace(array('{{{', '}}}'), array('{{', '}}'), $text); $text = str_replace(array('{{{', '}}}'), array('{{', '}}'), $text);
} else { } else {

View File

@ -1450,6 +1450,8 @@
"core.block.blocks": "moodle", "core.block.blocks": "moodle",
"core.block.noblocks": "error", "core.block.noblocks": "error",
"core.block.opendrawerblocks": "moodle", "core.block.opendrawerblocks": "moodle",
"core.block.tour_navigation_dashboard_content": "tool_usertours",
"core.block.tour_navigation_dashboard_title": "tool_usertours",
"core.browser": "local_moodlemobileapp", "core.browser": "local_moodlemobileapp",
"core.cancel": "moodle", "core.cancel": "moodle",
"core.cannotconnect": "local_moodlemobileapp", "core.cannotconnect": "local_moodlemobileapp",
@ -1567,6 +1569,8 @@
"core.course.startdate": "moodle", "core.course.startdate": "moodle",
"core.course.thisweek": "format_weeks/currentsection", "core.course.thisweek": "format_weeks/currentsection",
"core.course.todo": "completion", "core.course.todo": "completion",
"core.course.tour_navigation_course_index_student_content": "tool_usertours",
"core.course.tour_navigation_course_index_student_title": "tool_usertours",
"core.course.useactivityonbrowser": "local_moodlemobileapp", "core.course.useactivityonbrowser": "local_moodlemobileapp",
"core.course.viewcourse": "block_timeline", "core.course.viewcourse": "block_timeline",
"core.course.warningmanualcompletionmodified": "local_moodlemobileapp", "core.course.warningmanualcompletionmodified": "local_moodlemobileapp",
@ -2003,6 +2007,8 @@
"core.mainmenu.home": "moodle", "core.mainmenu.home": "moodle",
"core.mainmenu.logout": "moodle", "core.mainmenu.logout": "moodle",
"core.mainmenu.switchaccount": "local_moodlemobileapp", "core.mainmenu.switchaccount": "local_moodlemobileapp",
"core.mainmenu.usermenutourdescription": "local_moodlemobileapp",
"core.mainmenu.usermenutourtitle": "local_moodlemobileapp",
"core.maxfilesize": "moodle", "core.maxfilesize": "moodle",
"core.maxsizeandattachments": "moodle", "core.maxsizeandattachments": "moodle",
"core.min": "moodle", "core.min": "moodle",
@ -2263,6 +2269,7 @@
"core.submit": "moodle", "core.submit": "moodle",
"core.success": "moodle", "core.success": "moodle",
"core.summary": "moodle", "core.summary": "moodle",
"core.swipenavigationtourdescription": "local_moodlemobileapp",
"core.tablet": "local_moodlemobileapp", "core.tablet": "local_moodlemobileapp",
"core.tag.defautltagcoll": "tag", "core.tag.defautltagcoll": "tag",
"core.tag.errorareanotsupported": "local_moodlemobileapp", "core.tag.errorareanotsupported": "local_moodlemobileapp",
@ -2335,6 +2342,7 @@
"core.usernotfullysetup": "error", "core.usernotfullysetup": "error",
"core.users": "moodle", "core.users": "moodle",
"core.usersuspended": "tool_reportbuilder", "core.usersuspended": "tool_reportbuilder",
"core.endonesteptour": "tool_usertours",
"core.view": "moodle", "core.view": "moodle",
"core.viewcode": "local_moodlemobileapp", "core.viewcode": "local_moodlemobileapp",
"core.vieweditor": "local_moodlemobileapp", "core.vieweditor": "local_moodlemobileapp",

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 206 143"><rect x=".5" y="11.017" width="180.187" height="112.617" rx="6.143" fill="#EAF6FF"/><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="6" y="11" width="170" height="113"><rect x="6.106" y="11.017" width="168.975" height="112.917" rx="6.143" fill="#EAF6FF"/></mask><g mask="url(#a)"><rect x="77.78" y="20.627" width="25.627" height="6.407" rx="3.203" fill="#86CBFF"/><rect x="28.529" y="37.444" width="124.129" height="10.411" rx="5.205" fill="#86CBFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.944 46.226a1.793 1.793 0 0 1-2.689.083l-4.859-5.178a1.793 1.793 0 1 1 2.615-2.454l3.533 3.765 3.646-4.05a1.793 1.793 0 0 1 2.665 2.4l-4.75 5.277c-.052.056-.105.108-.161.157Z" fill="#86CBFF"/><rect x="28.827" y="56.309" width="92.653" height="10.238" rx="5.119" fill="#C5E6FF"/><circle cx="17.821" cy="61.172" r="6.911" fill="#C5E6FF"/><rect x="28.827" y="76.537" width="78.32" height="10.238" rx="5.119" fill="#C5E6FF"/><circle cx="17.821" cy="81.4" r="6.911" fill="#C5E6FF"/><rect x="28.827" y="96.764" width="110.569" height="10.238" rx="5.119" fill="#C5E6FF"/><circle cx="17.821" cy="101.628" r="6.911" fill="#C5E6FF"/><rect x="28.827" y="116.992" width="90.605" height="10.238" rx="5.119" fill="#C5E6FF"/><circle cx="17.821" cy="121.855" r="6.911" fill="#C5E6FF"/></g><g filter="url(#b)"><rect x="155.45" y="90.299" width="48.05" height="48.05" rx="24.025" fill="#3880FF"/><path d="M170.466 106.952a1.638 1.638 0 1 0 0 3.276 1.638 1.638 0 0 0 0-3.276ZM174.561 107.362a1.228 1.228 0 1 0 0 2.457h14.742a1.229 1.229 0 1 0 0-2.457h-14.742ZM174.561 113.095a1.229 1.229 0 1 0 0 2.457h14.742a1.228 1.228 0 1 0 0-2.457h-14.742ZM173.332 120.057c0-.679.55-1.229 1.229-1.229h14.742a1.229 1.229 0 1 1 0 2.457h-14.742c-.679 0-1.229-.55-1.229-1.228ZM168.828 114.324a1.638 1.638 0 1 1 3.275 0 1.638 1.638 0 0 1-3.275 0ZM170.466 118.419a1.638 1.638 0 1 0 0 3.275 1.638 1.638 0 0 0 0-3.275Z" fill="#fff"/></g><path d="m166.471 0-11.559 20.02h23.118L166.471 0Zm-2.002 79.282a2.002 2.002 0 1 0 4.004 0h-4.004Zm0-69.844a2.002 2.002 0 1 0 4.004 0h-4.004Zm4.004 7.551a2.002 2.002 0 1 0-4.004 0h4.004Zm-4.004 18.877a2.002 2.002 0 1 0 4.004 0h-4.004Zm4.004 7.55a2.002 2.002 0 1 0-4.004 0h4.004Zm-4.004 18.877a2.002 2.002 0 1 0 4.004 0h-4.004Zm4.004 7.55a2.002 2.002 0 1 0-4.004 0h4.004Zm-4.004-52.854v18.877h4.004V16.989h-4.004Zm0 26.427v18.877h4.004V43.416h-4.004Zm0 26.428v9.438h4.004v-9.438h-4.004Z" fill="#0056B3"/><defs><filter id="b" x="153.266" y="90.299" width="52.418" height="52.418" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset dy="2.184"/><feGaussianBlur stdDeviation="1.092"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 0.120972 0 0 0 0 0.203233 0 0 0 0 0.279167 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_319_9155"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_319_9155" result="shape"/></filter></defs></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 202 98"><path fill-rule="evenodd" clip-rule="evenodd" d="M98.269 84.491c1.654.528 10.173 2.987 25.555 7.378l13.172-21.987 1.308-12.462-11.649-8.533-12.69-4.665 5.051-19.274-3.955-4.534-5.864 1.961-7.052 26.911-3.439-.148-5.382 2.166-4.591 11.492 7.54 15.381c-.324 3.682.342 5.787 1.996 6.314Z" fill="#fff"/><path d="M117.797 47.53c-1.169-.307-2.275-.156-3.317.452-1.043.608-1.717 1.494-2.022 2.66l-1.106 4.22-1.058-.277.803-3.067c.277-1.055 6.047-23.565 6.047-23.565.381-1.454-.597-3.654-2.779-4.226-2.182-.571-4.113.866-4.494 2.32-.381 1.455-1.345 5.078-1.61 6.09l-8.252 31.489-.8-1.267 1.487-5.672c.276-1.055.158-2.044-.354-2.965-.513-.922-1.298-1.52-2.356-1.798-1.015-.266-1.98-.132-2.898.403-.918.535-1.51 1.308-1.774 2.32l-1.935 7.387c-.27 1.033-.121 2.036.449 3.007l7.694 12.449c.636 1.083.772 2.316.409 3.702-.15.571-.07 1.12.239 1.648.31.528.751.867 1.324 1.017l21.166 5.546c.573.15 1.124.072 1.652-.236.528-.308.867-.748 1.017-1.32l.216-.824a9.733 9.733 0 0 1 .996-2.453l7.338-13.442c.428-.734.76-1.552.996-2.453l2.126-8.113c.276-1.055.158-2.043-.355-2.965-.512-.921-1.298-1.52-2.356-1.798-1.014-.266-1.98-.131-2.897.404-.918.534-1.509 1.308-1.774 2.319l-.277 1.055-1.058-.277 1.08-4.122a3.95 3.95 0 0 0-.201-2.608c-.364-.859-.952-1.489-1.766-1.89a5.8 5.8 0 0 0-.743-.266c-1.015-.265-1.981-.13-2.898.404-.918.535-1.509 1.308-1.774 2.32l-1.106 4.22-1.058-.277 1.054-4.023a4.577 4.577 0 0 0-.302-3.233 4.098 4.098 0 0 0-2.352-2.15c-.105-.05-.245-.1-.421-.145Zm1.105-4.222c1.852.486 3.351 1.466 4.496 2.94a8.08 8.08 0 0 1 4.361-.055 8.136 8.136 0 0 1 3.438 1.853 8.036 8.036 0 0 1 2.187 3.251 8.043 8.043 0 0 1 2.012.28c2.205.579 3.873 1.856 5.004 3.832 1.132 1.977 1.41 4.064.834 6.263l-2.126 8.112c-.293 1.121-.748 2.271-1.363 3.45l-7.371 13.433c-.271.493-.541 1.256-.812 2.29-.461 1.759-1.47 3.092-3.027 4-1.557.907-3.218 1.13-4.981.668l-21.165-5.547c-1.852-.485-3.205-1.527-4.059-3.125-.853-1.598-1.038-3.32-.554-5.168l-7.629-12.43c-1.255-2.045-1.589-4.188-1.001-6.43l1.935-7.388c.57-2.176 1.835-3.836 3.796-4.978 1.96-1.143 4.032-1.428 6.214-.856.243.063.951.26 1.056.311.679-2.054 5.921-22.284 6.778-25.557.929-3.545 6.497-4.029 8.679-3.457 2.182.572 6.916 3.269 5.868 7.269-.191.727-4.365 16.347-4.461 16.71 0 0-.357-.26 1.891.33Z" fill="#0056B3"/><path d="M101.728 32.565c-.812.436-1.832.135-2.178-.719a15.246 15.246 0 0 1-1.051-7.043 15.418 15.418 0 0 1 3.22-8.154 15.566 15.566 0 0 1 7.157-5.113 15.504 15.504 0 0 1 8.774-.415 15.306 15.306 0 0 1 7.547 4.42 15.209 15.209 0 0 1 3.872 7.819c.5 2.93.132 5.947-1.057 8.685a15.51 15.51 0 0 1-4.319 5.75c-.702.582-1.735.37-2.242-.39-.507-.76-.292-1.78.394-2.386a12.146 12.146 0 0 0 3.112-4.273 12.014 12.014 0 0 0 .829-6.81 11.927 11.927 0 0 0-3.036-6.13 12.005 12.005 0 0 0-5.917-3.465 12.158 12.158 0 0 0-6.88.324 12.207 12.207 0 0 0-5.612 4.01 12.092 12.092 0 0 0-2.525 6.393 11.95 11.95 0 0 0 .695 5.187c.319.864.028 1.873-.783 2.31Z" fill="#86CBFF"/><rect x="76.039" y="12.141" width="23.308" height="3.759" rx="1.88" fill="#86CBFF"/><rect x="70.619" y="21.163" width="21.805" height="3.759" rx="1.88" fill="#86CBFF"/><rect x="80.394" y="30.186" width="16.541" height="3.759" rx="1.88" fill="#86CBFF"/><path d="M141.5 20.788a1.88 1.88 0 1 0 0 3.759v-3.76Zm60.15 1.88-18.797-10.853V33.52l18.797-10.853Zm-60.15 1.879h43.233v-3.76H141.5v3.76ZM61.15 24.547a1.88 1.88 0 0 0 0-3.76v3.76ZM1 22.667 19.797 33.52V11.815L1 22.667Zm60.15-1.88H17.917v3.76H61.15v-3.76Z" fill="#0056B3"/></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 269 173"><path d="M130.298 171.955a1.999 1.999 0 1 0 2.404-3.196l-2.404 3.196Zm137.887-153.65a2 2 0 0 0 .426-2.796l-10.665-14.5a2 2 0 1 0-3.222 2.37l9.48 12.89-12.889 9.48a2 2 0 1 0 2.37 3.222l14.5-10.665ZM124.847 36.115c14.837-13.515 33.816-20.396 57.372-22.633 23.592-2.242 51.644.187 84.48 5.19l.602-3.954c-32.918-5.016-61.365-7.507-85.46-5.218-24.131 2.292-44.025 9.391-59.688 23.657l2.694 2.958Zm34.44 79.302c-13.382 1.869-23.94-.552-31.827-5.493-7.898-4.946-13.278-12.519-16.124-21.225-5.717-17.49-1.113-39.265 13.511-52.584l-2.694-2.958c-15.876 14.46-20.786 37.916-14.619 56.785 3.096 9.473 9.008 17.864 17.803 23.372 8.804 5.515 20.34 8.043 34.504 6.064l-.554-3.961Zm-26.585 53.343c-25.76-19.374-35.531-44.979-33.124-66.267 2.404-21.255 16.912-38.345 40.27-41.227l-.49-3.97c-25.369 3.13-41.163 21.836-43.754 44.747-2.587 22.876 7.954 49.803 34.694 69.913l2.404-3.196Zm7.146-107.494c23.592-2.91 41.413 9.316 46.813 22.682 2.682 6.636 2.305 13.48-1.745 19.109-4.087 5.679-12.172 10.48-25.629 12.36l.554 3.961c14.089-1.968 23.372-7.107 28.321-13.985 4.986-6.928 5.312-15.26 2.208-22.943-6.171-15.272-25.876-28.255-51.012-25.154l.49 3.97Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -137,6 +137,13 @@ export class CoreDatabaseTableProxy<
return this.target.hasAny(conditions); return this.target.hasAny(conditions);
} }
/**
* @inheritdoc
*/
hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise<boolean> {
return this.target.hasAnyByPrimaryKey(primaryKey);
}
/** /**
* @inheritdoc * @inheritdoc
*/ */

View File

@ -219,6 +219,23 @@ export class CoreDatabaseTable<
} }
} }
/**
* Check whether the table has any record matching the given primary key.
*
* @param primaryKey Record primary key.
* @returns Whether the table contains a record matching the given primary key.
*/
async hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise<boolean> {
try {
await this.getOneByPrimaryKey(primaryKey);
return true;
} catch (error) {
// Couldn't get the record.
return false;
}
}
/** /**
* Count records in table. * Count records in table.
* *

View File

@ -129,6 +129,15 @@ export class CoreDebugDatabaseTable<
return this.target.hasAny(conditions); return this.target.hasAny(conditions);
} }
/**
* @inheritdoc
*/
hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise<boolean> {
this.logger.log('hasAnyByPrimaryKey', primaryKey);
return this.target.hasAnyByPrimaryKey(primaryKey);
}
/** /**
* @inheritdoc * @inheritdoc
*/ */

View File

@ -134,6 +134,13 @@ export class CoreEagerDatabaseTable<
: Object.values(this.records).length > 0; : Object.values(this.records).length > 0;
} }
/**
* @inheritdoc
*/
async hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise<boolean> {
return this.serializePrimaryKey(primaryKey) in this.records;
}
/** /**
* @inheritdoc * @inheritdoc
*/ */

View File

@ -125,6 +125,15 @@ export class CoreLazyDatabaseTable<
return super.hasAny(conditions); return super.hasAny(conditions);
} }
/**
* @inheritdoc
*/
async hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise<boolean> {
const record = this.records[this.serializePrimaryKey(primaryKey)] ?? null;
return record !== null;
}
/** /**
* @inheritdoc * @inheritdoc
*/ */

View File

@ -60,6 +60,7 @@ import { CoreSpacerComponent } from './spacer/spacer';
import { CoreHorizontalScrollControlsComponent } from './horizontal-scroll-controls/horizontal-scroll-controls'; import { CoreHorizontalScrollControlsComponent } from './horizontal-scroll-controls/horizontal-scroll-controls';
import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-with-spinner'; import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-with-spinner';
import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides'; import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides';
import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-navigation-tour';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -102,6 +103,7 @@ import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides';
CoreComboboxComponent, CoreComboboxComponent,
CoreSpacerComponent, CoreSpacerComponent,
CoreHorizontalScrollControlsComponent, CoreHorizontalScrollControlsComponent,
CoreSwipeNavigationTourComponent,
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -151,6 +153,7 @@ import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides';
CoreComboboxComponent, CoreComboboxComponent,
CoreSpacerComponent, CoreSpacerComponent,
CoreHorizontalScrollControlsComponent, CoreHorizontalScrollControlsComponent,
CoreSwipeNavigationTourComponent,
], ],
}) })
export class CoreComponentsModule {} export class CoreComponentsModule {}

View File

@ -0,0 +1,5 @@
<img src="assets/img/user-tours/swipe-navigation.svg" alt="" />
<p>{{ 'core.swipenavigationtourdescription' | translate }}</p>
<ion-button (click)="dismiss()" expand="block">
{{ 'core.endonesteptour' | translate }}
</ion-button>

View File

@ -0,0 +1,12 @@
:host {
max-width: 85vw;
img {
max-width: 300px;
}
p {
text-align: center;
}
}

View File

@ -0,0 +1,35 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component } from '@angular/core';
import { CoreUserTours } from '@features/usertours/services/user-tours';
/**
* Component showing the User Tour for the Swipe Navigation feature.
*/
@Component({
selector: 'core-swipe-navigation-tour',
templateUrl: 'core-swipe-navigation-tour.html',
styleUrls: ['swipe-navigation-tour.scss'],
})
export class CoreSwipeNavigationTourComponent {
/**
* Dismiss User Tour.
*/
async dismiss(): Promise<void> {
await CoreUserTours.dismiss();
}
}

View File

@ -190,6 +190,9 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
}; };
this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated)); this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated));
this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated));
onOutletUpdated();
onOutletUpdated(); onOutletUpdated();
@ -311,6 +314,10 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
* @param content Content element. * @param content Content element.
*/ */
protected updateContent(content?: HTMLIonContentElement | null): void { protected updateContent(content?: HTMLIonContentElement | null): void {
if (content === (this.content ?? null)) {
return;
}
if (this.content) { if (this.content) {
if (this.contentScrollListener) { if (this.contentScrollListener) {
this.content.removeEventListener('ionScroll', this.contentScrollListener); this.content.removeEventListener('ionScroll', this.contentScrollListener);
@ -321,6 +328,7 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener); this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener);
delete this.endContentScrollListener; delete this.endContentScrollListener;
} }
delete this.content; delete this.content;
} }

View File

@ -32,6 +32,7 @@ import { CoreSwipeNavigationDirective } from './swipe-navigation';
import { CoreCollapsibleItemDirective } from './collapsible-item'; import { CoreCollapsibleItemDirective } from './collapsible-item';
import { CoreCollapsibleFooterDirective } from './collapsible-footer'; import { CoreCollapsibleFooterDirective } from './collapsible-footer';
import { CoreContentDirective } from './content'; import { CoreContentDirective } from './content';
import { CoreOnAppearDirective } from './on-appear';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -46,6 +47,7 @@ import { CoreContentDirective } from './content';
CoreSupressEventsDirective, CoreSupressEventsDirective,
CoreUserLinkDirective, CoreUserLinkDirective,
CoreAriaButtonClickDirective, CoreAriaButtonClickDirective,
CoreOnAppearDirective,
CoreOnResizeDirective, CoreOnResizeDirective,
CoreDownloadFileDirective, CoreDownloadFileDirective,
CoreCollapsibleHeaderDirective, CoreCollapsibleHeaderDirective,
@ -66,6 +68,7 @@ import { CoreContentDirective } from './content';
CoreSupressEventsDirective, CoreSupressEventsDirective,
CoreUserLinkDirective, CoreUserLinkDirective,
CoreAriaButtonClickDirective, CoreAriaButtonClickDirective,
CoreOnAppearDirective,
CoreOnResizeDirective, CoreOnResizeDirective,
CoreDownloadFileDirective, CoreDownloadFileDirective,
CoreCollapsibleHeaderDirective, CoreCollapsibleHeaderDirective,

View File

@ -0,0 +1,58 @@
// (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 { Directive, ElementRef, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
import { CoreDomUtils } from '@services/utils/dom';
/**
* Directive to listen when an element becomes visible.
*/
@Directive({
selector: '[onAppear]',
})
export class CoreOnAppearDirective implements OnInit, OnDestroy {
@Output() onAppear = new EventEmitter();
private element: HTMLElement;
private interval?: number;
constructor(element: ElementRef) {
this.element = element.nativeElement;
}
/**
* @inheritdoc
*/
ngOnInit(): void {
this.interval = window.setInterval(() => {
if (!CoreDomUtils.isElementVisible(this.element)) {
return;
}
this.onAppear.emit();
window.clearInterval(this.interval);
delete this.interval;
}, 50);
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.interval && window.clearInterval(this.interval);
}
}

View File

@ -14,6 +14,8 @@
import { AfterViewInit, Directive, ElementRef, Input, OnDestroy } from '@angular/core'; import { AfterViewInit, Directive, ElementRef, Input, OnDestroy } from '@angular/core';
import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager';
import { CoreSwipeNavigationTourComponent } from '@components/swipe-navigation-tour/swipe-navigation-tour';
import { CoreUserTours } from '@features/usertours/services/user-tours';
import { Gesture, GestureDetail } from '@ionic/angular'; import { Gesture, GestureDetail } from '@ionic/angular';
import { CoreScreen } from '@services/screen'; import { CoreScreen } from '@services/screen';
import { GestureController, Platform } from '@singletons'; import { GestureController, Platform } from '@singletons';
@ -50,7 +52,8 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy {
/** /**
* @inheritdoc * @inheritdoc
*/ */
ngAfterViewInit(): void { async ngAfterViewInit(): Promise<void> {
// Set up gesture listener
const style = this.element.style; const style = this.element.style;
this.swipeGesture = GestureController.create({ this.swipeGesture = GestureController.create({
el: this.element, el: this.element,
@ -71,6 +74,26 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy {
}, },
}); });
this.swipeGesture.enable(); this.swipeGesture.enable();
// Show user tour.
const source = this.manager?.getSource();
if (!source) {
return;
}
await source.waitForLoaded();
const items = source.getItems() ?? [];
if (items.length < 2) {
return;
}
await CoreUserTours.showIfPending({
id: 'swipe-navigation',
component: CoreSwipeNavigationTourComponent,
});
} }
/** /**

View File

@ -19,6 +19,7 @@ import { CoreBlockPreRenderedComponent } from './pre-rendered-block/pre-rendered
import { CoreSharedModule } from '@/core/shared.module'; import { CoreSharedModule } from '@/core/shared.module';
import { CoreBlockSideBlocksComponent } from './side-blocks/side-blocks'; import { CoreBlockSideBlocksComponent } from './side-blocks/side-blocks';
import { CoreBlockSideBlocksButtonComponent } from './side-blocks-button/side-blocks-button'; import { CoreBlockSideBlocksButtonComponent } from './side-blocks-button/side-blocks-button';
import { CoreBlockSideBlocksTourComponent } from './side-blocks-tour/side-blocks-tour';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -27,6 +28,7 @@ import { CoreBlockSideBlocksButtonComponent } from './side-blocks-button/side-bl
CoreBlockPreRenderedComponent, CoreBlockPreRenderedComponent,
CoreBlockSideBlocksComponent, CoreBlockSideBlocksComponent,
CoreBlockSideBlocksButtonComponent, CoreBlockSideBlocksButtonComponent,
CoreBlockSideBlocksTourComponent,
], ],
imports: [ imports: [
CoreSharedModule, CoreSharedModule,
@ -37,6 +39,7 @@ import { CoreBlockSideBlocksButtonComponent } from './side-blocks-button/side-bl
CoreBlockPreRenderedComponent, CoreBlockPreRenderedComponent,
CoreBlockSideBlocksComponent, CoreBlockSideBlocksComponent,
CoreBlockSideBlocksButtonComponent, CoreBlockSideBlocksButtonComponent,
CoreBlockSideBlocksTourComponent,
], ],
}) })
export class CoreBlockComponentsModule {} export class CoreBlockComponentsModule {}

View File

@ -1,3 +1,4 @@
<ion-button (click)="openBlocks()" [attr.aria-label]="'core.block.opendrawerblocks' | translate" color="secondary"> <ion-button (click)="openBlocks()" (onAppear)="showTour()" [attr.aria-label]="'core.block.opendrawerblocks' | translate" color="secondary"
#button>
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon> <ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button> </ion-button>

View File

@ -12,8 +12,10 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { Component, Input } from '@angular/core'; import { Component, ElementRef, Input, ViewChild } from '@angular/core';
import { CoreUserTours, CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreBlockSideBlocksTourComponent } from '../side-blocks-tour/side-blocks-tour';
import { CoreBlockSideBlocksComponent } from '../side-blocks/side-blocks'; import { CoreBlockSideBlocksComponent } from '../side-blocks/side-blocks';
/** /**
@ -27,6 +29,7 @@ import { CoreBlockSideBlocksComponent } from '../side-blocks/side-blocks';
export class CoreBlockSideBlocksButtonComponent { export class CoreBlockSideBlocksButtonComponent {
@Input() courseId!: number; @Input() courseId!: number;
@ViewChild('button', { read: ElementRef }) button?: ElementRef<HTMLElement>;
/** /**
* Open side blocks. * Open side blocks.
@ -40,4 +43,23 @@ export class CoreBlockSideBlocksButtonComponent {
}); });
} }
/**
* Show User Tour.
*/
async showTour(): Promise<void> {
const nativeButton = this.button?.nativeElement.shadowRoot?.children[0] as HTMLElement;
if (!nativeButton) {
return;
}
await CoreUserTours.showIfPending({
id: 'side-blocks-button',
component: CoreBlockSideBlocksTourComponent,
focus: nativeButton,
side: CoreUserToursSide.Start,
alignment: CoreUserToursAlignment.Center,
});
}
} }

View File

@ -0,0 +1,6 @@
<h2>{{ 'core.block.tour_navigation_dashboard_title' | translate }}</h2>
<img src="assets/img/user-tours/side-blocks.svg" alt="" />
<p>{{ 'core.block.tour_navigation_dashboard_content' | translate }}</p>
<ion-button (click)="dismiss()" expand="block">
{{ 'core.endonesteptour' | translate }}
</ion-button>

View File

@ -0,0 +1,15 @@
:host {
h2 {
margin-top: 0;
}
p {
text-align: center;
}
ion-button {
margin: 0;
}
}

View File

@ -0,0 +1,35 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component } from '@angular/core';
import { CoreUserTours } from '@features/usertours/services/user-tours';
/**
* Component showing the User Tour for the Side Blocks feature.
*/
@Component({
selector: 'core-block-side-blocks-tour',
templateUrl: 'side-blocks-tour.html',
styleUrls: ['side-blocks-tour.scss'],
})
export class CoreBlockSideBlocksTourComponent {
/**
* Dismiss User Tour.
*/
async dismiss(): Promise<void> {
await CoreUserTours.dismiss();
}
}

View File

@ -1,5 +1,7 @@
{ {
"blocks": "Blocks", "blocks": "Blocks",
"noblocks": "No blocks found!", "noblocks": "No blocks found!",
"opendrawerblocks": "Open block drawer" "opendrawerblocks": "Open block drawer",
"tour_navigation_dashboard_content": "This side panel can contain more features.",
"tour_navigation_dashboard_title": "Expand to explore"
} }

View File

@ -28,6 +28,7 @@ import { CoreCourseModuleInfoComponent } from './module-info/module-info';
import { CoreCourseModuleManualCompletionComponent } from './module-manual-completion/module-manual-completion'; import { CoreCourseModuleManualCompletionComponent } from './module-manual-completion/module-manual-completion';
import { CoreCourseModuleNavigationComponent } from './module-navigation/module-navigation'; import { CoreCourseModuleNavigationComponent } from './module-navigation/module-navigation';
import { CoreCourseModuleSummaryComponent } from './module-summary/module-summary'; import { CoreCourseModuleSummaryComponent } from './module-summary/module-summary';
import { CoreCourseCourseIndexTourComponent } from './course-index-tour/course-index-tour';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -39,6 +40,7 @@ import { CoreCourseModuleSummaryComponent } from './module-summary/module-summar
CoreCourseModuleInfoComponent, CoreCourseModuleInfoComponent,
CoreCourseModuleManualCompletionComponent, CoreCourseModuleManualCompletionComponent,
CoreCourseCourseIndexComponent, CoreCourseCourseIndexComponent,
CoreCourseCourseIndexTourComponent,
CoreCourseTagAreaComponent, CoreCourseTagAreaComponent,
CoreCourseUnsupportedModuleComponent, CoreCourseUnsupportedModuleComponent,
CoreCourseModuleNavigationComponent, CoreCourseModuleNavigationComponent,
@ -57,6 +59,7 @@ import { CoreCourseModuleSummaryComponent } from './module-summary/module-summar
CoreCourseModuleInfoComponent, CoreCourseModuleInfoComponent,
CoreCourseModuleManualCompletionComponent, CoreCourseModuleManualCompletionComponent,
CoreCourseCourseIndexComponent, CoreCourseCourseIndexComponent,
CoreCourseCourseIndexTourComponent,
CoreCourseTagAreaComponent, CoreCourseTagAreaComponent,
CoreCourseUnsupportedModuleComponent, CoreCourseUnsupportedModuleComponent,
CoreCourseModuleNavigationComponent, CoreCourseModuleNavigationComponent,

View File

@ -49,7 +49,8 @@
<!-- Course Index button. --> <!-- Course Index button. -->
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="loaded && displayCourseIndex"> <ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="loaded && displayCourseIndex">
<ion-fab-button (click)="openCourseIndex()" [attr.aria-label]="'core.course.courseindex' | translate" color="secondary"> <ion-fab-button (click)="openCourseIndex()" (onAppear)="showCourseIndexTour()" [attr.aria-label]="'core.course.courseindex' | translate"
color="secondary" #courseIndexFab>
<ion-icon name="fas-list-ul" aria-hidden="true"></ion-icon> <ion-icon name="fas-list-ul" aria-hidden="true"></ion-icon>
<span class="sr-only">{{'core.course.courseindex' | translate }}</span> <span class="sr-only">{{'core.course.courseindex' | translate }}</span>
</ion-fab-button> </ion-fab-button>

View File

@ -23,6 +23,7 @@ import {
QueryList, QueryList,
Type, Type,
ElementRef, ElementRef,
ViewChild,
} from '@angular/core'; } from '@angular/core';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component';
@ -44,6 +45,8 @@ import { CoreBlockHelper } from '@features/block/services/block-helper';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
import { CoreCourseViewedModulesDBRecord } from '@features/course/services/database/course'; import { CoreCourseViewedModulesDBRecord } from '@features/course/services/database/course';
import { CoreUserTours, CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
import { CoreCourseCourseIndexTourComponent } from '../course-index-tour/course-index-tour';
/** /**
* Component to display course contents using a certain format. If the format isn't found, use default one. * Component to display course contents using a certain format. If the format isn't found, use default one.
@ -71,6 +74,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
@Input() moduleId?: number; // The module ID to scroll to. Must be inside the initial selected section. @Input() moduleId?: number; // The module ID to scroll to. Must be inside the initial selected section.
@ViewChildren(CoreDynamicComponent) dynamicComponents?: QueryList<CoreDynamicComponent>; @ViewChildren(CoreDynamicComponent) dynamicComponents?: QueryList<CoreDynamicComponent>;
@ViewChild('courseIndexFab', { read: ElementRef }) courseIndexFab?: ElementRef<HTMLElement>;
// All the possible component classes. // All the possible component classes.
courseFormatComponent?: Type<unknown>; courseFormatComponent?: Type<unknown>;
@ -160,6 +164,25 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
}); });
} }
/**
* Show Course Index User Tour.
*/
async showCourseIndexTour(): Promise<void> {
const nativeButton = this.courseIndexFab?.nativeElement.shadowRoot?.children[0] as HTMLElement;
if (!nativeButton) {
return;
}
await CoreUserTours.showIfPending({
id: 'course-index',
component: CoreCourseCourseIndexTourComponent,
focus: nativeButton,
side: CoreUserToursSide.Top,
alignment: CoreUserToursAlignment.End,
});
}
/** /**
* Detect changes on input properties. * Detect changes on input properties.
*/ */

View File

@ -0,0 +1,6 @@
<h2>{{ 'core.course.tour_navigation_course_index_student_title' | translate }}</h2>
<img src="assets/img/user-tours/course-index.svg" alt="" />
<p>{{ 'core.course.tour_navigation_course_index_student_content' | translate }}</p>
<ion-button (click)="dismiss()" expand="block">
{{ 'core.endonesteptour' | translate }}
</ion-button>

View File

@ -0,0 +1,15 @@
:host {
h2 {
margin-top: 0;
}
p {
text-align: center;
}
ion-button {
margin: 0;
}
}

View File

@ -0,0 +1,35 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component } from '@angular/core';
import { CoreUserTours } from '@features/usertours/services/user-tours';
/**
* Component showing the User Tour for the Course Index feature.
*/
@Component({
selector: 'core-course-course-index-tour',
templateUrl: 'course-index-tour.html',
styleUrls: ['course-index-tour.scss'],
})
export class CoreCourseCourseIndexTourComponent {
/**
* Dismiss the User Tour.
*/
async dismiss(): Promise<void> {
await CoreUserTours.dismiss();
}
}

View File

@ -54,6 +54,8 @@
"startdate": "Course start date", "startdate": "Course start date",
"thisweek": "This week", "thisweek": "This week",
"todo": "To do", "todo": "To do",
"tour_navigation_course_index_student_content": "Browse through activities and track your progress.",
"tour_navigation_course_index_student_title": "Find your way around",
"useactivityonbrowser": "You can still use it using your device's web browser.", "useactivityonbrowser": "You can still use it using your device's web browser.",
"viewcourse": "View course", "viewcourse": "View course",
"warningmanualcompletionmodified": "The manual completion of an activity was modified on the site.", "warningmanualcompletionmodified": "The manual completion of an activity was modified on the site.",

View File

@ -37,6 +37,7 @@ import { CoreSiteHomeModule } from './sitehome/sitehome.module';
import { CoreSitePluginsModule } from './siteplugins/siteplugins.module'; import { CoreSitePluginsModule } from './siteplugins/siteplugins.module';
import { CoreStylesModule } from './styles/styles.module'; import { CoreStylesModule } from './styles/styles.module';
import { CoreTagModule } from './tag/tag.module'; import { CoreTagModule } from './tag/tag.module';
import { CoreUserToursModule } from './usertours/user-tours.module';
import { CoreUserModule } from './user/user.module'; import { CoreUserModule } from './user/user.module';
import { CoreViewerModule } from './viewer/viewer.module'; import { CoreViewerModule } from './viewer/viewer.module';
import { CoreXAPIModule } from './xapi/xapi.module'; import { CoreXAPIModule } from './xapi/xapi.module';
@ -66,6 +67,7 @@ import { CoreXAPIModule } from './xapi/xapi.module';
CoreSitePluginsModule, CoreSitePluginsModule,
CoreTagModule, CoreTagModule,
CoreStylesModule, CoreStylesModule,
CoreUserToursModule,
CoreUserModule, CoreUserModule,
CoreViewerModule, CoreViewerModule,
CoreXAPIModule, CoreXAPIModule,

View File

@ -17,11 +17,13 @@ import { CoreSharedModule } from '@/core/shared.module';
import { CoreMainMenuUserButtonComponent } from './user-menu-button/user-menu-button'; import { CoreMainMenuUserButtonComponent } from './user-menu-button/user-menu-button';
import { CoreMainMenuUserMenuComponent } from './user-menu/user-menu'; import { CoreMainMenuUserMenuComponent } from './user-menu/user-menu';
import { CoreLoginComponentsModule } from '@features/login/components/components.module'; import { CoreLoginComponentsModule } from '@features/login/components/components.module';
import { CoreMainMenuUserMenuTourComponent } from './user-menu-tour/user-menu-tour';
@NgModule({ @NgModule({
declarations: [ declarations: [
CoreMainMenuUserButtonComponent, CoreMainMenuUserButtonComponent,
CoreMainMenuUserMenuComponent, CoreMainMenuUserMenuComponent,
CoreMainMenuUserMenuTourComponent,
], ],
imports: [ imports: [
CoreSharedModule, CoreSharedModule,
@ -30,6 +32,7 @@ import { CoreLoginComponentsModule } from '@features/login/components/components
exports: [ exports: [
CoreMainMenuUserButtonComponent, CoreMainMenuUserButtonComponent,
CoreMainMenuUserMenuComponent, CoreMainMenuUserMenuComponent,
CoreMainMenuUserMenuTourComponent,
], ],
}) })
export class CoreMainMenuComponentsModule {} export class CoreMainMenuComponentsModule {}

View File

@ -1,3 +1,4 @@
<core-user-avatar *ngIf="isMainScreen && siteInfo" [user]="siteInfo" class="core-bar-button-image clickable" [linkProfile]="false" <core-user-avatar *ngIf="isMainScreen && siteInfo" [user]="siteInfo" class="core-bar-button-image clickable" [linkProfile]="false"
(ariaButtonClick)="openUserMenu($event)" role="button" tabindex="0" [attr.aria-label]="'core.user.useraccount' | translate"> (ariaButtonClick)="openUserMenu($event)" (onAppear)="showTour()" role="button" tabindex="0"
[attr.aria-label]="'core.user.useraccount' | translate" #avatar>
</core-user-avatar> </core-user-avatar>

View File

@ -12,11 +12,13 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { Component, OnInit } from '@angular/core'; import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { CoreSiteInfo } from '@classes/site'; import { CoreSiteInfo } from '@classes/site';
import { CoreUserTours, CoreUserToursStyle } from '@features/usertours/services/user-tours';
import { IonRouterOutlet } from '@ionic/angular'; import { IonRouterOutlet } from '@ionic/angular';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreMainMenuUserMenuTourComponent } from '../user-menu-tour/user-menu-tour';
import { CoreMainMenuUserMenuComponent } from '../user-menu/user-menu'; import { CoreMainMenuUserMenuComponent } from '../user-menu/user-menu';
/** /**
@ -34,6 +36,8 @@ export class CoreMainMenuUserButtonComponent implements OnInit {
siteInfo?: CoreSiteInfo; siteInfo?: CoreSiteInfo;
isMainScreen = false; isMainScreen = false;
@ViewChild('avatar', { read: ElementRef }) avatar?: ElementRef<HTMLElement>;
constructor(protected routerOutlet: IonRouterOutlet) { constructor(protected routerOutlet: IonRouterOutlet) {
const currentSite = CoreSites.getRequiredCurrentSite(); const currentSite = CoreSites.getRequiredCurrentSite();
@ -61,4 +65,20 @@ export class CoreMainMenuUserButtonComponent implements OnInit {
}); });
} }
/**
* Show User Tour.
*/
async showTour(): Promise<void> {
if (!this.avatar) {
return;
}
await CoreUserTours.showIfPending({
id: 'user-menu',
component: CoreMainMenuUserMenuTourComponent,
focus: this.avatar.nativeElement,
style: CoreUserToursStyle.Overlay,
});
}
} }

View File

@ -0,0 +1,6 @@
<img src="assets/img/user-tours/user-menu.svg" alt="" />
<h2>{{ 'core.mainmenu.usermenutourtitle' | translate }}</h2>
<p>{{ 'core.mainmenu.usermenutourdescription' | translate }}</p>
<ion-button (click)="dismiss()" expand="block">
{{ 'core.endonesteptour' | translate }}
</ion-button>

View File

@ -0,0 +1,26 @@
:host {
width: 100%;
height: 100%;
display: flex;
max-width: 85vw;
align-items: center;
flex-direction: column;
img {
width: calc(100vw - var(--core-avatar-size) * 2 - 16px);
margin-top: 12px;
}
p {
text-align: center;
}
ion-button {
width: 100%;
}
}
:host-context([dir=rtl]) img {
transform: scaleX(-1);
}

View File

@ -0,0 +1,35 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component } from '@angular/core';
import { CoreUserTours } from '@features/usertours/services/user-tours';
/**
* Component showing the User Tour for the User Menu feature.
*/
@Component({
selector: 'core-mainmenu-user-menu-tour',
templateUrl: 'user-menu-tour.html',
styleUrls: ['user-menu-tour.scss'],
})
export class CoreMainMenuUserMenuTourComponent {
/**
* Dismiss the User Tour.
*/
async dismiss(): Promise<void> {
await CoreUserTours.dismiss();
}
}

View File

@ -1,5 +1,7 @@
{ {
"home": "Home", "home": "Home",
"logout": "Log out", "logout": "Log out",
"switchaccount": "Switch account" "switchaccount": "Switch account",
"usermenutourdescription": "The place to check your grades, change your preferences or switch accounts.",
"usermenutourtitle": "Explore your personal area"
} }

View File

@ -0,0 +1,50 @@
// (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 { renderInlineStyles } from '@/core/utils/style-helpers';
/**
* Helper class to calculate layout styles for the focused area in a User Tour.
*/
export class CoreUserToursFocusLayout {
inlineStyles!: string;
private targetBoundingBox: DOMRect;
private targetComputedStyle: CSSStyleDeclaration;
constructor(target: HTMLElement) {
this.targetBoundingBox = target.getBoundingClientRect();
this.targetComputedStyle = window.getComputedStyle(target);
this.calculateStyles();
}
/**
* Calculate styles.
*/
private calculateStyles(): void {
this.inlineStyles = renderInlineStyles({
'top': this.targetBoundingBox.top,
'left': this.targetBoundingBox.left,
'width': this.targetBoundingBox.width,
'height': this.targetBoundingBox.height,
'border-top-left-radius': this.targetComputedStyle.borderTopLeftRadius,
'border-top-right-radius': this.targetComputedStyle.borderTopRightRadius,
'border-bottom-left-radius': this.targetComputedStyle.borderBottomLeftRadius,
'border-bottom-right-radius': this.targetComputedStyle.borderBottomRightRadius,
});
}
}

View File

@ -0,0 +1,213 @@
// (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 { CoreStyles, renderInlineStyles } from '@/core/utils/style-helpers';
import { Platform } from '@singletons';
import { CoreUserToursAlignment, CoreUserToursSide } from '../services/user-tours';
const ARROW_HEIGHT = 22;
const ARROW_WIDTH = 35;
const BORDER_RADIUS = 8;
const MARGIN = 16;
/**
* Helper class to calculate layout styles for the popover wrapper in a User Tour.
*/
export class CoreUserToursPopoverLayout {
wrapperStyles: CoreStyles;
wrapperInlineStyles!: string;
wrapperArrowStyles: CoreStyles;
wrapperArrowInlineStyles!: string;
private targetBoundingBox: DOMRect;
private side: CoreUserToursSide;
private alignment: CoreUserToursAlignment;
constructor(target: HTMLElement, side: CoreUserToursSide, alignment: CoreUserToursAlignment) {
this.targetBoundingBox = target.getBoundingClientRect();
this.side = side;
this.alignment = alignment;
this.wrapperArrowStyles = {};
this.wrapperStyles = {};
this.calculateStyles();
}
/**
* Calculate styles.
*/
private calculateStyles(): void {
const sideHandlers: Record<CoreUserToursSide, () => void> = {
[CoreUserToursSide.Top]: this.calculateWrapperTopSideStyles,
[CoreUserToursSide.Bottom]: this.calculateWrapperBottomSideStyles,
[CoreUserToursSide.Right]: this.calculateWrapperRightSideStyles,
[CoreUserToursSide.Left]: this.calculateWrapperLeftSideStyles,
[CoreUserToursSide.Start]: Platform.isRTL ? this.calculateWrapperRightSideStyles : this.calculateWrapperLeftSideStyles,
[CoreUserToursSide.End]: Platform.isRTL ? this.calculateWrapperLeftSideStyles : this.calculateWrapperRightSideStyles,
};
sideHandlers[this.side].call(this);
this.wrapperInlineStyles = renderInlineStyles(this.wrapperStyles);
this.wrapperArrowInlineStyles = renderInlineStyles(this.wrapperArrowStyles);
}
/**
* Calculate wrapper styles for an horizontal alignment.
*/
private calculateWrapperHorizontalAlignmentStyles(): void {
const horizontalAlignmentHandlers: Record<CoreUserToursAlignment, () => void> ={
[CoreUserToursAlignment.Start]: Platform.isRTL
? this.calculateWrapperRightAlignmentStyles
: this.calculateWrapperLeftAlignmentStyles,
[CoreUserToursAlignment.Center]: this.calculateWrapperCenterHorizontalAlignmentStyles,
[CoreUserToursAlignment.End]: Platform.isRTL
? this.calculateWrapperLeftAlignmentStyles
: this.calculateWrapperRightAlignmentStyles,
};
horizontalAlignmentHandlers[this.alignment].call(this);
}
/**
* Calculate wrapper styles for a vertical alignment.
*/
private calculateWrapperVerticalAlignmentStyles(): void {
const verticalAlignmentHandlers: Record<CoreUserToursAlignment, () => void> ={
[CoreUserToursAlignment.Start]: this.calculateWrapperTopAlignmentStyles,
[CoreUserToursAlignment.Center]: this.calculateWrapperCenterVerticalAlignmentStyles,
[CoreUserToursAlignment.End]: this.calculateWrapperBottomAlignmentStyles,
};
verticalAlignmentHandlers[this.alignment].call(this);
}
/**
* Calculate wrapper arrow styles for an horizontal orientation.
*/
private calculateWrapperArrowHorizontalStyles(): void {
this.wrapperArrowStyles['border-width'] = `${ARROW_WIDTH / 2}px ${ARROW_HEIGHT}px`;
}
/**
* Calculate wrapper arrow styles for a vertical orientation.
*/
private calculateWrapperArrowVerticalStyles(): void {
this.wrapperArrowStyles['border-width'] = `${ARROW_HEIGHT}px ${ARROW_WIDTH / 2}px`;
}
/**
* Calculate wrapper styles for a top side placement.
*/
private calculateWrapperTopSideStyles(): void {
this.wrapperStyles.bottom = window.innerHeight - this.targetBoundingBox.y + ARROW_HEIGHT + MARGIN;
this.wrapperArrowStyles.bottom = -ARROW_HEIGHT*2;
this.wrapperArrowStyles['border-top-color'] = 'var(--popover-background)';
this.calculateWrapperArrowVerticalStyles();
this.calculateWrapperHorizontalAlignmentStyles();
}
/**
* Calculate wrapper styles for a bottom side placement.
*/
private calculateWrapperBottomSideStyles(): void {
this.wrapperStyles.top = this.targetBoundingBox.y + this.targetBoundingBox.height + ARROW_HEIGHT + MARGIN;
this.wrapperArrowStyles.top = -ARROW_HEIGHT*2;
this.wrapperArrowStyles['border-bottom-color'] = 'var(--popover-background)';
this.calculateWrapperArrowVerticalStyles();
this.calculateWrapperHorizontalAlignmentStyles();
}
/**
* Calculate wrapper styles for a right side placement.
*/
private calculateWrapperRightSideStyles(): void {
this.wrapperStyles.left = this.targetBoundingBox.x + this.targetBoundingBox.width + ARROW_HEIGHT + MARGIN;
this.wrapperArrowStyles.left = -ARROW_HEIGHT*2;
this.wrapperArrowStyles['border-right-color'] = 'var(--popover-background)';
this.calculateWrapperArrowHorizontalStyles();
this.calculateWrapperVerticalAlignmentStyles();
}
/**
* Calculate wrapper styles for a left side placement.
*/
private calculateWrapperLeftSideStyles(): void {
this.wrapperStyles.right = window.innerWidth - this.targetBoundingBox.x + ARROW_HEIGHT + MARGIN;
this.wrapperArrowStyles.right = -ARROW_HEIGHT*2;
this.wrapperArrowStyles['border-left-color'] = 'var(--popover-background)';
this.calculateWrapperArrowHorizontalStyles();
this.calculateWrapperVerticalAlignmentStyles();
}
/**
* Calculate wrapper styles for top alignment.
*/
private calculateWrapperTopAlignmentStyles() {
this.wrapperStyles.top = this.targetBoundingBox.y;
this.wrapperArrowStyles.top = BORDER_RADIUS;
}
/**
* Calculate wrapper styles for bottom alignment.
*/
private calculateWrapperBottomAlignmentStyles(): void {
this.wrapperStyles.bottom = window.innerHeight - this.targetBoundingBox.y - this.targetBoundingBox.height;
this.wrapperArrowStyles.bottom = BORDER_RADIUS;
}
/**
* Calculate wrapper styles for right alignment.
*/
private calculateWrapperRightAlignmentStyles() {
this.wrapperStyles.right = window.innerWidth - this.targetBoundingBox.x - this.targetBoundingBox.width;
this.wrapperArrowStyles.right = BORDER_RADIUS;
}
/**
* Calculate wrapper styles for left alignment.
*/
private calculateWrapperLeftAlignmentStyles() {
this.wrapperStyles.left = this.targetBoundingBox.x;
this.wrapperArrowStyles.left = BORDER_RADIUS;
}
/**
* Calculate wrapper styles for center horizontal alignment.
*/
private calculateWrapperCenterHorizontalAlignmentStyles() {
this.wrapperStyles.left = this.targetBoundingBox.x + this.targetBoundingBox.width / 2;
this.wrapperStyles.transform = 'translateX(-50%)';
this.wrapperStyles['transform-origin'] = '0 50%';
this.wrapperArrowStyles.left = '50%';
this.wrapperArrowStyles.transform = 'translateX(-50%)';
}
/**
* Calculate wrapper styles for center vertical alignment.
*/
private calculateWrapperCenterVerticalAlignmentStyles() {
this.wrapperStyles.top = this.targetBoundingBox.y + this.targetBoundingBox.height / 2;
this.wrapperStyles.transform = 'translateY(-50%)';
this.wrapperStyles['transform-origin'] = '50% 0';
this.wrapperArrowStyles.top = '50%';
this.wrapperArrowStyles.transform = 'translateY(-50%)';
}
}

View File

@ -0,0 +1,34 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreUserToursUserTourComponent } from './user-tour/user-tour';
/**
* User Tours components module.
*/
@NgModule({
declarations: [
CoreUserToursUserTourComponent,
],
imports: [
CoreSharedModule,
],
exports: [
CoreUserToursUserTourComponent,
],
})
export class CoreUserToursComponentsModule {}

View File

@ -0,0 +1,5 @@
<div *ngIf="focusStyles" class="user-tour-focus" [style]="focusStyles"></div>
<div *ngIf="!focusStyles" class="user-tour-overlay"></div>
<div class="user-tour-wrapper" [style]="popoverWrapperStyles" #wrapper>
<span *ngIf="popover" class="user-tour-wrapper-arrow" [style]="popoverWrapperArrowStyles"></span>
</div>

View File

@ -0,0 +1,61 @@
:host {
--popover-background: var(--ion-overlay-background-color, var(--ion-background-color, #fff));
z-index: 99;
width: 100%;
height: 100%;
display: none;
color: white;
.user-tour-focus {
position: absolute;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.75);
}
.user-tour-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.75);
}
.user-tour-wrapper {
position: absolute;
}
&.is-active {
display: block;
}
&.is-popover .user-tour-wrapper {
color: var(--ion-text-color, #000);
background: var(--popover-background);
width: 70vw;
padding: 16px;
border-radius: 8px;
.user-tour-wrapper-arrow {
position: absolute;
border-style: solid;
border-color: transparent;
}
}
&:not(.is-popover) .user-tour-wrapper {
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
:host-context(body.dark) {
--popover-background: var(--gray-700);
}

View File

@ -0,0 +1,168 @@
// (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 { AfterViewInit, Component, ElementRef, HostBinding, Input, ViewChild } from '@angular/core';
import { CorePromisedValue } from '@classes/promised-value';
import { CoreUserToursFocusLayout } from '@features/usertours/classes/focus-layout';
import { CoreUserToursPopoverLayout } from '@features/usertours/classes/popover-layout';
import {
CoreUserTours,
CoreUserToursAlignment,
CoreUserToursSide,
CoreUserToursStyle,
} from '@features/usertours/services/user-tours';
import { CoreDomUtils } from '@services/utils/dom';
import { AngularFrameworkDelegate } from '@singletons';
import { CoreComponentsRegistry } from '@singletons/components-registry';
const ANIMATION_DURATION = 200;
/**
* User Tour wrapper component.
*
* User Tours content will be rendered within this component according to the configured style.
*/
@Component({
selector: 'core-user-tours-user-tour',
templateUrl: 'core-user-tours-user-tour.html',
styleUrls: ['user-tour.scss'],
})
export class CoreUserToursUserTourComponent implements AfterViewInit {
@Input() container!: HTMLElement;
@Input() id!: string;
@Input() component!: unknown;
@Input() componentProps?: Record<string, unknown>;
@Input() focus?: HTMLElement;
@Input() style?: CoreUserToursStyle; // When this is undefined in a tour with a focused element, popover style will be used.
@Input() side?: CoreUserToursSide;
@Input() alignment?: CoreUserToursAlignment;
@HostBinding('class.is-active') active = false;
@HostBinding('class.is-popover') popover = false;
@ViewChild('wrapper') wrapper?: ElementRef<HTMLElement>;
focusStyles?: string;
popoverWrapperStyles?: string;
popoverWrapperArrowStyles?: string;
private element: HTMLElement;
private wrapperTransform = '';
private wrapperElement = new CorePromisedValue<HTMLElement>();
constructor({ nativeElement: element }: ElementRef<HTMLElement>) {
this.element = element;
CoreComponentsRegistry.register(element, this);
}
/**
* @inheritdoc
*/
ngAfterViewInit(): void {
if (!this.wrapper) {
return;
}
this.wrapperElement.resolve(this.wrapper.nativeElement);
}
/**
* Present User Tour.
*/
async present(): Promise<void> {
// Insert tour component and wait until it's ready.
const wrapper = await this.wrapperElement;
const tour = await AngularFrameworkDelegate.attachViewToDom(wrapper, this.component, this.componentProps ?? {});
await CoreDomUtils.waitForImages(tour);
this.calculateStyles();
// Show tour.
this.active = true;
await this.playEnterAnimation();
}
/**
* Dismiss User Tour.
*
* @param acknowledge Whether to confirm that the user has seen the User Tour.
*/
async dismiss(acknowledge: boolean = true): Promise<void> {
await this.playLeaveAnimation();
AngularFrameworkDelegate.removeViewFromDom(this.container, this.element);
acknowledge && CoreUserTours.acknowledge(this.id);
}
/**
* Calculate inline styles.
*/
private calculateStyles(): void {
if (!this.focus) {
return;
}
// Calculate focus styles.
const focusLayout = new CoreUserToursFocusLayout(this.focus);
this.focusStyles = focusLayout.inlineStyles;
// Calculate popup styles.
if ((this.style ?? CoreUserToursStyle.Popover) === CoreUserToursStyle.Popover) {
if (!this.side || !this.alignment) {
throw new Error('Cannot create a popover user tour without side and alignment');
}
const popoverLayout = new CoreUserToursPopoverLayout(this.focus, this.side, this.alignment);
this.popover = true;
this.popoverWrapperStyles = popoverLayout.wrapperInlineStyles;
this.popoverWrapperArrowStyles = popoverLayout.wrapperArrowInlineStyles;
this.wrapperTransform = `${popoverLayout.wrapperStyles.transform ?? ''}`;
}
}
/**
* Play animation to show that the User Tour has started.
*/
private async playEnterAnimation(): Promise<void> {
const animations = [
this.element.animate({ opacity: ['0', '1'] }, { duration: ANIMATION_DURATION }),
this.wrapperElement.value?.animate(
{ transform: [`scale(1.2) ${this.wrapperTransform}`, `scale(1) ${this.wrapperTransform}`] },
{ duration: ANIMATION_DURATION },
),
];
await Promise.all(animations.map(animation => animation?.finished));
}
/**
* Play animation to show that the User Tour has endd.
*/
private async playLeaveAnimation(): Promise<void> {
const animations = [
this.element.animate({ opacity: ['1', '0'] }, { duration: ANIMATION_DURATION }),
this.wrapperElement.value?.animate(
{ transform: [`scale(1) ${this.wrapperTransform}`, `scale(1.2) ${this.wrapperTransform}`] },
{ duration: ANIMATION_DURATION },
),
];
await Promise.all(animations.map(animation => animation?.finished));
}
}

View File

@ -0,0 +1,48 @@
// (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 { CoreAppSchema } from '@services/app';
/**
* Database variables for CoreUserTours service.
*/
export const USER_TOURS_TABLE_NAME = 'user_tours';
export const APP_SCHEMA: CoreAppSchema = {
name: 'CoreUserTours',
version: 1,
tables: [
{
name: USER_TOURS_TABLE_NAME,
columns: [
{
name: 'id',
type: 'TEXT',
primaryKey: true,
},
{
name: 'acknowledgedTime',
type: 'INTEGER',
},
],
},
],
};
/**
* User Tours database entry.
*/
export type CoreUserToursDBEntry = {
id: string;
acknowledgedTime: number;
};

View File

@ -0,0 +1,262 @@
// (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 { CoreConstants } from '@/core/constants';
import { asyncInstance } from '@/core/utils/async-instance';
import { Injectable } from '@angular/core';
import { CoreDatabaseTable } from '@classes/database/database-table';
import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy';
import { CoreApp } from '@services/app';
import { CoreUtils } from '@services/utils/utils';
import { AngularFrameworkDelegate, makeSingleton } from '@singletons';
import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CoreUserToursUserTourComponent } from '../components/user-tour/user-tour';
import { APP_SCHEMA, CoreUserToursDBEntry, USER_TOURS_TABLE_NAME } from './database/user-tours';
/**
* Service to manage User Tours.
*/
@Injectable({ providedIn: 'root' })
export class CoreUserToursService {
protected table = asyncInstance<CoreDatabaseTable<CoreUserToursDBEntry>>();
protected tours: CoreUserToursUserTourComponent[] = [];
protected tourReadyCallbacks = new WeakMap<CoreUserToursUserTourComponent, () => void>();
/**
* Initialize database.
*/
async initializeDatabase(): Promise<void> {
await CoreUtils.ignoreErrors(CoreApp.createTablesFromSchema(APP_SCHEMA));
this.table.setLazyConstructor(async () => {
const table = new CoreDatabaseTableProxy<CoreUserToursDBEntry>(
{ cachingStrategy: CoreDatabaseCachingStrategy.Eager },
CoreApp.getDB(),
USER_TOURS_TABLE_NAME,
['id'],
);
await table.initialize();
return table;
});
}
/**
* Check whether a User Tour is pending or not.
*
* @param id User Tour id.
* @returns Whether the User Tour is pending or not.
*/
async isPending(id: string): Promise<boolean> {
if (CoreConstants.CONFIG.disableUserTours || CoreConstants.CONFIG.disabledUserTours?.includes(id)) {
return false;
}
const isAcknowledged = await this.table.hasAnyByPrimaryKey({ id });
return !isAcknowledged;
}
/**
* Confirm that a User Tour has been seen by the user.
*
* @param id User Tour id.
*/
async acknowledge(id: string): Promise<void> {
await this.table.insert({ id, acknowledgedTime: Date.now() });
}
/**
* Show a User Tour if it's pending.
*
* @param options User Tour options.
*/
async showIfPending(options: CoreUserToursBasicOptions): Promise<void>;
async showIfPending(options: CoreUserToursPopoverFocusedOptions): Promise<void>;
async showIfPending(options: CoreUserToursOverlayFocusedOptions): Promise<void>;
async showIfPending(options: CoreUserToursOptions): Promise<void> {
const isPending = await CoreUserTours.isPending(options.id);
if (!isPending) {
return;
}
return this.show(options);
}
/**
* Show a User Tour.
*
* @param options User Tour options.
*/
protected async show(options: CoreUserToursBasicOptions): Promise<void>;
protected async show(options: CoreUserToursPopoverFocusedOptions): Promise<void>;
protected async show(options: CoreUserToursOverlayFocusedOptions): Promise<void>;
protected async show(options: CoreUserToursOptions): Promise<void> {
const { delay, ...componentOptions } = options;
await CoreUtils.wait(delay ?? 200);
const container = document.querySelector('ion-app') ?? document.body;
const element = await AngularFrameworkDelegate.attachViewToDom(
container,
CoreUserToursUserTourComponent,
{ ...componentOptions, container },
);
const tour = CoreComponentsRegistry.require(element, CoreUserToursUserTourComponent);
this.tours.push(tour);
this.tours.length > 1
? await new Promise<void>(resolve => this.tourReadyCallbacks.set(tour, resolve))
: await tour.present();
}
/**
* Dismiss the active User Tour, if any.
*
* @param acknowledge Whether to acknowledge that the user has seen this User Tour or not.
*/
async dismiss(acknowledge: boolean = true): Promise<void> {
if (this.tours.length === 0) {
return;
}
const activeTour = this.tours.shift() as CoreUserToursUserTourComponent;
const nextTour = this.tours[0] as CoreUserToursUserTourComponent | undefined;
await Promise.all([
activeTour.dismiss(acknowledge),
nextTour?.present(),
]);
nextTour && this.tourReadyCallbacks.get(nextTour)?.();
}
}
export const CoreUserTours = makeSingleton(CoreUserToursService);
/**
* User Tour style.
*/
export const enum CoreUserToursStyle {
Overlay = 'overlay',
Popover = 'popover',
}
/**
* User Tour side.
*/
export const enum CoreUserToursSide {
Top = 'top',
Bottom = 'bottom',
Right = 'right',
Left = 'left',
Start = 'start',
End = 'end',
}
/**
* User Tour alignment.
*/
export const enum CoreUserToursAlignment {
Start = 'start',
Center = 'center',
End = 'end',
}
/**
* Basic options to create a User Tour.
*/
export interface CoreUserToursBasicOptions {
/**
* Unique identifier.
*/
id: string;
/**
* User Tour component.
*/
component: unknown;
/**
* Properties to pass to the User Tour component.
*/
componentProps?: Record<string, unknown>;
/**
* Milliseconds to wait until the User Tour is shown.
*
* Defaults to 200ms.
*/
delay?: number;
}
/**
* Options to create a focused User Tour.
*/
export interface CoreUserToursFocusedOptions extends CoreUserToursBasicOptions {
/**
* Element to focus.
*/
focus: HTMLElement;
}
/**
* Options to create a focused User Tour using the Popover style.
*/
export interface CoreUserToursPopoverFocusedOptions extends CoreUserToursFocusedOptions {
/**
* User Tour style.
*/
style?: CoreUserToursStyle.Popover;
/**
* Position relative to the focused element.
*/
side: CoreUserToursSide;
/**
* Alignment relative to the focused element.
*/
alignment: CoreUserToursAlignment;
}
/**
* Options to create a focused User Tour using the Overlay style.
*/
export interface CoreUserToursOverlayFocusedOptions extends CoreUserToursFocusedOptions {
/**
* User Tour style.
*/
style: CoreUserToursStyle.Overlay;
}
/**
* Options to create a User Tour.
*/
export type CoreUserToursOptions =
CoreUserToursBasicOptions |
CoreUserToursPopoverFocusedOptions |
CoreUserToursOverlayFocusedOptions;

View File

@ -0,0 +1,37 @@
// (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 { CoreUserToursComponentsModule } from './components/components.module';
import { CoreUserTours } from './services/user-tours';
/**
* User Tours module.
*/
@NgModule({
imports: [
CoreUserToursComponentsModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
useValue: async () => {
await CoreUserTours.initializeDatabase();
},
},
],
})
export class CoreUserToursModule {}

View File

@ -302,6 +302,7 @@
"submit": "Submit", "submit": "Submit",
"success": "Success", "success": "Success",
"summary": "Summary", "summary": "Summary",
"swipenavigationtourdescription": "Swipe left and right to navigate around.",
"tablet": "Tablet", "tablet": "Tablet",
"teachers": "Teachers", "teachers": "Teachers",
"thereisdatatosync": "There are offline {{$a}} to be synchronised.", "thereisdatatosync": "There are offline {{$a}} to be synchronised.",
@ -329,6 +330,7 @@
"usernotfullysetup": "User not fully set-up", "usernotfullysetup": "User not fully set-up",
"usernologin": "Authentication has been revoked for this account", "usernologin": "Authentication has been revoked for this account",
"usersuspended": "Registration suspended", "usersuspended": "Registration suspended",
"endonesteptour": "Got it",
"users": "Users", "users": "Users",
"view": "View", "view": "View",
"viewcode": "View code", "viewcode": "View code",

View File

@ -23,7 +23,7 @@ import { CoreEvents } from '@singletons/events';
import { CoreDatabaseTable } from '@classes/database/database-table'; import { CoreDatabaseTable } from '@classes/database/database-table';
import { asyncInstance } from '../utils/async-instance'; import { asyncInstance } from '../utils/async-instance';
import { CorePromisedValue } from '@classes/promised-value'; import { CorePromisedValue } from '@classes/promised-value';
import { CoreUtils } from './utils/utils'; import { CoreBrowser } from '@singletons/browser';
declare module '@singletons/events' { declare module '@singletons/events' {
@ -195,11 +195,11 @@ export class CoreConfigProvider {
* Load development config overrides. * Load development config overrides.
*/ */
protected loadDevelopmentConfig(): void { protected loadDevelopmentConfig(): void {
if (!CoreConstants.enableDevTools() || !CoreUtils.hasCookie('MoodleAppConfig')) { if (!CoreConstants.enableDevTools() || !CoreBrowser.hasCookie('MoodleAppConfig')) {
return; return;
} }
this.patchEnvironment(JSON.parse(CoreUtils.getCookie('MoodleAppConfig') ?? '{}')); this.patchEnvironment(JSON.parse(CoreBrowser.getCookie('MoodleAppConfig') ?? '{}'));
} }
/** /**

View File

@ -16,9 +16,9 @@ import { Injectable } from '@angular/core';
import { SQLiteDB } from '@classes/sqlitedb'; import { SQLiteDB } from '@classes/sqlitedb';
import { SQLiteDBMock } from '@features/emulator/classes/sqlitedb'; import { SQLiteDBMock } from '@features/emulator/classes/sqlitedb';
import { CoreBrowser } from '@singletons/browser';
import { makeSingleton, SQLite, Platform } from '@singletons'; import { makeSingleton, SQLite, Platform } from '@singletons';
import { CoreAppProvider } from './app'; import { CoreAppProvider } from './app';
import { CoreUtils } from './utils/utils';
/** /**
* This service allows interacting with the local database to store and retrieve data. * This service allows interacting with the local database to store and retrieve data.
@ -36,7 +36,7 @@ export class CoreDbProvider {
* @returns Whether queries should be logged. * @returns Whether queries should be logged.
*/ */
loggingEnabled(): boolean { loggingEnabled(): boolean {
return CoreUtils.hasCookie('MoodleAppDBLoggingEnabled') || CoreAppProvider.isAutomated(); return CoreBrowser.hasCookie('MoodleAppDBLoggingEnabled') || CoreAppProvider.isAutomated();
} }
/** /**

View File

@ -806,6 +806,24 @@ export class CoreDomUtilsProvider {
return elementPoint > window.innerHeight || elementPoint < scrollTopPos; return elementPoint > window.innerHeight || elementPoint < scrollTopPos;
} }
/**
* Check whether an element is visible or not.
*
* @param element Element.
*/
isElementVisible(element: HTMLElement): boolean {
if (element.clientWidth === 0 || element.clientHeight === 0) {
return false;
}
const style = getComputedStyle(element);
if (style.opacity === '0' || style.display === 'none' || style.visibility === 'hidden') {
return false;
}
return element.offsetParent !== null;
}
/** /**
* Check if rich text editor is enabled. * Check if rich text editor is enabled.
* *

View File

@ -1765,34 +1765,6 @@ export class CoreUtilsProvider {
return CoreApp.isIOS() && openFileAction == OpenFileAction.OPEN_WITH; return CoreApp.isIOS() && openFileAction == OpenFileAction.OPEN_WITH;
} }
/**
* Check whether the given cookie is set.
*
* @param name Cookie name.
* @returns Whether the cookie is set.
*/
hasCookie(name: string): boolean {
return new RegExp(`(\\s|;|^)${name}=`).test(document.cookie ?? '');
}
/**
* Read a cookie.
*
* @param name Cookie name.
* @return Cookie value.
*/
getCookie(name: string): string | null {
const cookies = (document.cookie ?? '').split(';').reduce((cookies, cookie) => {
const [name, value] = cookie.trim().split('=');
cookies[name] = value;
return cookies;
}, {});
return cookies[name] ?? null;
}
} }
export const CoreUtils = makeSingleton(CoreUtilsProvider); export const CoreUtils = makeSingleton(CoreUtilsProvider);

View File

@ -0,0 +1,48 @@
// (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.
/**
* Helpers to interact with Browser APIs.
*/
export class CoreBrowser {
/**
* Check whether the given cookie is set.
*
* @param name Cookie name.
* @returns Whether the cookie is set.
*/
static hasCookie(name: string): boolean {
return new RegExp(`(\\s|;|^)${name}=`).test(document.cookie ?? '');
}
/**
* Read a cookie.
*
* @param name Cookie name.
* @return Cookie value.
*/
static getCookie(name: string): string | null {
const cookies = (document.cookie ?? '').split(';').reduce((cookies, cookie) => {
const [name, value] = cookie.trim().split('=');
cookies[name] = value;
return cookies;
}, {});
return cookies[name] ?? null;
}
}

View File

@ -47,6 +47,23 @@ export class CoreComponentsRegistry {
: null; : null;
} }
/**
* Get a component instances and fail if it cannot be resolved.
*
* @param element Root element.
* @param componentClass Component class.
* @returns Component instance.
*/
static require<T>(element: Element, componentClass?: ComponentConstructor<T>): T {
const instance = this.resolve(element, componentClass);
if (!instance) {
throw new Error('Couldn\'t resolve component instance');
}
return instance;
}
/** /**
* Waits all elements to be rendered. * Waits all elements to be rendered.
* *

View File

@ -12,13 +12,22 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { AbstractType, ApplicationInitStatus, ApplicationRef, Injector, NgZone as NgZoneService, Type } from '@angular/core'; import {
AbstractType,
ApplicationInitStatus,
ApplicationRef,
ComponentFactoryResolver as ComponentFactoryResolverService,
Injector,
NgZone as NgZoneService,
Type,
} from '@angular/core';
import { Router as RouterService } from '@angular/router'; import { Router as RouterService } from '@angular/router';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { DomSanitizer as DomSanitizerService } from '@angular/platform-browser'; import { DomSanitizer as DomSanitizerService } from '@angular/platform-browser';
import { import {
Platform as PlatformService, Platform as PlatformService,
AngularDelegate as AngularDelegateService,
AlertController as AlertControllerService, AlertController as AlertControllerService,
LoadingController as LoadingControllerService, LoadingController as LoadingControllerService,
ModalController as ModalControllerService, ModalController as ModalControllerService,
@ -58,11 +67,13 @@ import { Zip as ZipService } from '@ionic-native/zip/ngx';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { CoreApplicationInitStatus } from '@classes/application-init-status'; import { CoreApplicationInitStatus } from '@classes/application-init-status';
import { asyncInstance } from '@/core/utils/async-instance';
import { CorePromisedValue } from '@classes/promised-value';
/** /**
* Injector instance used to resolve singletons. * Injector instance used to resolve singletons.
*/ */
let singletonsInjector: Injector | null = null; const singletonsInjector = new CorePromisedValue<Injector>();
/** /**
* Helper to create a method that proxies calls to the underlying singleton instance. * Helper to create a method that proxies calls to the underlying singleton instance.
@ -87,7 +98,7 @@ export type CoreSingletonProxy<Service = unknown> = Service & {
* @param injector Module injector. * @param injector Module injector.
*/ */
export function setSingletonsInjector(injector: Injector): void { export function setSingletonsInjector(injector: Injector): void {
singletonsInjector = injector; singletonsInjector.resolve(injector);
} }
/** /**
@ -127,11 +138,13 @@ export function makeSingleton<Service extends object = object>( // eslint-disabl
Object.defineProperty(singleton, 'instance', { Object.defineProperty(singleton, 'instance', {
get: () => { get: () => {
if (!singletonsInjector) { const injector = singletonsInjector.value;
if (!injector) {
throw new Error('Can\'t resolve a singleton instance without an injector'); throw new Error('Can\'t resolve a singleton instance without an injector');
} }
const instance = singletonsInjector.get(injectionToken); const instance = injector.get(injectionToken);
singleton.setInstance(instance); singleton.setInstance(instance);
@ -194,7 +207,9 @@ export const NgZone = makeSingleton(NgZoneService);
export const Http = makeSingleton(HttpClient); export const Http = makeSingleton(HttpClient);
export const Platform = makeSingleton(PlatformService); export const Platform = makeSingleton(PlatformService);
export const ActionSheetController = makeSingleton(ActionSheetControllerService); export const ActionSheetController = makeSingleton(ActionSheetControllerService);
export const AngularDelegate = makeSingleton(AngularDelegateService);
export const AlertController = makeSingleton(AlertControllerService); export const AlertController = makeSingleton(AlertControllerService);
export const ComponentFactoryResolver = makeSingleton(ComponentFactoryResolverService);
export const LoadingController = makeSingleton(LoadingControllerService); export const LoadingController = makeSingleton(LoadingControllerService);
export const ModalController = makeSingleton(ModalControllerService); export const ModalController = makeSingleton(ModalControllerService);
export const PopoverController = makeSingleton(PopoverControllerService); export const PopoverController = makeSingleton(PopoverControllerService);
@ -208,3 +223,10 @@ export const DomSanitizer = makeSingleton(DomSanitizerService);
// Convert external libraries injectables. // Convert external libraries injectables.
export const Translate = makeSingleton(TranslateService); export const Translate = makeSingleton(TranslateService);
// Async singletons.
export const AngularFrameworkDelegate = asyncInstance(async () => {
const injector = await singletonsInjector;
return AngularDelegate.create(ComponentFactoryResolver.instance, injector);
});

View File

@ -17,6 +17,7 @@ import moment from 'moment';
import { CoreConstants } from '@/core/constants'; import { CoreConstants } from '@/core/constants';
import { CoreTime } from './time'; import { CoreTime } from './time';
import { CoreBrowser } from '@singletons/browser';
/** /**
* Method to warn that logs are disabled, called only once. * Method to warn that logs are disabled, called only once.
@ -67,7 +68,10 @@ export class CoreLogger {
*/ */
static getInstance(className: string): CoreLogger { static getInstance(className: string): CoreLogger {
// Disable log on production and testing. // Disable log on production and testing.
if (CoreConstants.BUILD.isProduction || CoreConstants.BUILD.isTesting) { if (
!CoreBrowser.hasCookie('MoodleAppLoggingEnabled') &&
(CoreConstants.BUILD.isProduction || CoreConstants.BUILD.isTesting)
) {
if (CoreConstants.BUILD.isProduction) { if (CoreConstants.BUILD.isProduction) {
warnLogsDisabled(); warnLogsDisabled();
} }

View File

@ -54,6 +54,20 @@ function createAsyncInstanceWrapper<T>(lazyConstructor?: () => T | Promise<T>):
promisedInstance.resolve(instance); promisedInstance.resolve(instance);
}, },
setLazyConstructor(constructor) {
if (!promisedInstance) {
lazyConstructor = constructor;
return;
}
if (!promisedInstance.isResolved()) {
// eslint-disable-next-line promise/catch-or-return
Promise
.resolve(constructor())
.then(instance => promisedInstance?.isResolved() || promisedInstance?.resolve(instance));
}
},
resetInstance() { resetInstance() {
if (!promisedInstance) { if (!promisedInstance) {
return; return;
@ -72,6 +86,7 @@ export interface AsyncInstanceWrapper<T> {
getInstance(): Promise<T>; getInstance(): Promise<T>;
getProperty<P extends keyof T>(property: P): Promise<T[P]>; getProperty<P extends keyof T>(property: P): Promise<T[P]>;
setInstance(instance: T): void; setInstance(instance: T): void;
setLazyConstructor(lazyConstructor: () => T | Promise<T>): void;
resetInstance(): void; resetInstance(): void;
} }

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.
/**
* Element styles.
*
* Number styles are interpreted as pixels; any other values should be set as a string.
*/
export type CoreStyles = Record<string, string | number>;
/**
* Render the given styles to be used inline on an element.
*
* @param styles Styles.
* @returns Inline styles.
*/
export function renderInlineStyles(styles: CoreStyles): string {
return Object
.entries(styles)
.reduce((renderedStyles, [property, value]) => {
const propertyValue = typeof value === 'string' ? value : `${value}px`;
return `${property}:${propertyValue};${renderedStyles}`;
}, '');
}

View File

@ -34,6 +34,8 @@ export interface EnvironmentConfig {
languages: Record<string, string>; languages: Record<string, string>;
databaseOptimizations?: Partial<CoreDatabaseConfiguration>; databaseOptimizations?: Partial<CoreDatabaseConfiguration>;
databaseTableOptimizations?: Record<string, Partial<CoreDatabaseConfiguration>>; databaseTableOptimizations?: Record<string, Partial<CoreDatabaseConfiguration>>;
disableUserTours?: boolean;
disabledUserTours?: string[];
wsservice: string; wsservice: string;
demo_sites: Record<string, CoreSitesDemoSiteData>; demo_sites: Record<string, CoreSitesDemoSiteData>;
zoomlevels: Record<CoreZoomLevel, number>; zoomlevels: Record<CoreZoomLevel, number>;