MOBILE-2308 calendar: List events page
parent
f6083227b4
commit
a9a63e5668
|
@ -14,6 +14,7 @@
|
||||||
|
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { AddonCalendarProvider } from './providers/calendar';
|
import { AddonCalendarProvider } from './providers/calendar';
|
||||||
|
import { AddonCalendarHelperProvider } from './providers/helper';
|
||||||
import { AddonCalendarMainMenuHandler } from './providers/handlers';
|
import { AddonCalendarMainMenuHandler } from './providers/handlers';
|
||||||
import { CoreMainMenuDelegate } from '../../core/mainmenu/providers/delegate';
|
import { CoreMainMenuDelegate } from '../../core/mainmenu/providers/delegate';
|
||||||
|
|
||||||
|
@ -24,6 +25,7 @@ import { CoreMainMenuDelegate } from '../../core/mainmenu/providers/delegate';
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AddonCalendarProvider,
|
AddonCalendarProvider,
|
||||||
|
AddonCalendarHelperProvider,
|
||||||
AddonCalendarMainMenuHandler
|
AddonCalendarMainMenuHandler
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,7 +1,37 @@
|
||||||
<ion-header>
|
<ion-header>
|
||||||
<ion-navbar>
|
<ion-navbar>
|
||||||
<ion-title>{{ 'addon.calendar.calendarevents' | translate }}</ion-title>
|
<ion-title>{{ 'addon.calendar.calendarevents' | translate }}</ion-title>
|
||||||
|
<ion-buttons end>
|
||||||
|
<button *ngIf="courses && courses.length" ion-button icon-only (click)="openCourseFilter($event)" [attr.aria-label]="'core.courses.filter' | translate">
|
||||||
|
<ion-icon name="funnel"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<core-context-menu>
|
||||||
|
<core-context-menu-item [hidden]="!notificationsEnabled" [priority]="600" [content]="'core.settings.settings' | translate" (action)="openSettings()" [iconAction]="'cog'"></core-context-menu-item>
|
||||||
|
</core-context-menu>
|
||||||
|
</ion-buttons>
|
||||||
</ion-navbar>
|
</ion-navbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
<ion-content>
|
<ion-content>
|
||||||
|
<ion-refresher [enabled]="eventsLoaded" (ionRefresh)="refreshEvents($event)">
|
||||||
|
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||||
|
</ion-refresher>
|
||||||
|
<core-loading [hideUntil]="eventsLoaded" class="mm-loading-center">
|
||||||
|
<!-- @todo: Split view. -->
|
||||||
|
<core-empty-box *ngIf="!filteredEvents || !filteredEvents.length" icon="calendar" [message]="'addon.calendar.noevents' | translate">
|
||||||
|
</core-empty-box>
|
||||||
|
|
||||||
|
<ion-list *ngIf="filteredEvents && filteredEvents.length">
|
||||||
|
<a ion-item text-wrap *ngFor="let event of filteredEvents" [title]="event.name">
|
||||||
|
<!-- mm-split-view-link="site.calendar-event({id: event.id})" -->
|
||||||
|
<img *ngIf="event.moduleicon" src="{{event.moduleicon}}" item-start>
|
||||||
|
<ion-icon *ngIf="!event.moduleicon" name="{{event.icon}}" item-start></ion-icon>
|
||||||
|
<h2><core-format-text [text]="event.name"></core-format-text></h2>
|
||||||
|
<p>{{ event.timestart | coreToLocaleString }}</p>
|
||||||
|
</a>
|
||||||
|
</ion-list>
|
||||||
|
|
||||||
|
<ion-infinite-scroll [enabled]="canLoadMore" (ionInfinite)="$event.waitFor(fetchEvents())">
|
||||||
|
<ion-infinite-scroll-content></ion-infinite-scroll-content>
|
||||||
|
</ion-infinite-scroll>
|
||||||
|
</core-loading>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
|
|
@ -15,6 +15,9 @@
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { IonicPageModule } from 'ionic-angular';
|
import { IonicPageModule } from 'ionic-angular';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { CoreComponentsModule } from '../../../../components/components.module';
|
||||||
|
import { CoreDirectivesModule } from '../../../../directives/directives.module';
|
||||||
|
import { CorePipesModule } from '../../../../pipes/pipes.module';
|
||||||
import { AddonCalendarListPage } from './list';
|
import { AddonCalendarListPage } from './list';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -22,8 +25,11 @@ import { AddonCalendarListPage } from './list';
|
||||||
AddonCalendarListPage,
|
AddonCalendarListPage,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
|
CoreComponentsModule,
|
||||||
|
CoreDirectivesModule,
|
||||||
|
CorePipesModule,
|
||||||
IonicPageModule.forChild(AddonCalendarListPage),
|
IonicPageModule.forChild(AddonCalendarListPage),
|
||||||
TranslateModule.forChild()
|
TranslateModule.forChild()
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AddonCalendarListPagePageModule {}
|
export class AddonCalendarListPageModule {}
|
||||||
|
|
|
@ -12,12 +12,21 @@
|
||||||
// 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, OnDestroy } from '@angular/core';
|
import { Component, ViewChild, OnDestroy } from '@angular/core';
|
||||||
import { IonicPage } from 'ionic-angular';
|
import { IonicPage, Content, PopoverController, NavParams, NavController } from 'ionic-angular';
|
||||||
//import { AddonCalendarProvider } from '../../providers/calendar';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { AddonCalendarProvider } from '../../providers/calendar';
|
||||||
|
import { AddonCalendarHelperProvider } from '../../providers/helper';
|
||||||
|
import { CoreCoursesProvider } from '../../../../core/courses/providers/courses';
|
||||||
|
import { CoreDomUtilsProvider } from '../../../../providers/utils/dom';
|
||||||
|
import { CoreUtilsProvider } from '../../../../providers/utils/utils';
|
||||||
|
import { CoreSitesProvider } from '../../../../providers/sites';
|
||||||
|
import { CoreLocalNotificationsProvider } from '../../../../providers/local-notifications';
|
||||||
|
import { CoreCoursePickerMenuPopoverComponent } from '../../../../components/course-picker-menu/course-picker-menu-popover';
|
||||||
|
import { CoreEventsProvider } from '../../../../providers/events';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page that displays the list of courses the user is enrolled in.
|
* Page that displays the list of calendar events.
|
||||||
*/
|
*/
|
||||||
@IonicPage()
|
@IonicPage()
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -25,20 +34,277 @@ import { IonicPage } from 'ionic-angular';
|
||||||
templateUrl: 'list.html',
|
templateUrl: 'list.html',
|
||||||
})
|
})
|
||||||
export class AddonCalendarListPage implements OnDestroy {
|
export class AddonCalendarListPage implements OnDestroy {
|
||||||
eventsLoaded = false;
|
@ViewChild(Content) content: Content;
|
||||||
|
|
||||||
constructor() {}
|
protected daysLoaded = 0;
|
||||||
|
protected emptyEventsTimes = 0; // Variable to identify consecutive calls returning 0 events.
|
||||||
|
protected categoriesRetrieved = false;
|
||||||
|
protected getCategories = false;
|
||||||
|
protected allCourses = {
|
||||||
|
id: -1,
|
||||||
|
fullname: this.translate.instant('core.fulllistofcourses'),
|
||||||
|
category: -1
|
||||||
|
};
|
||||||
|
protected categories = {};
|
||||||
|
protected siteHomeId: number;
|
||||||
|
protected obsDefaultTimeChange: any;
|
||||||
|
|
||||||
|
courses: any[];
|
||||||
|
eventsLoaded = false;
|
||||||
|
events = [];
|
||||||
|
notificationsEnabled = false;
|
||||||
|
filteredEvents = [];
|
||||||
|
eventToLoad = 1;
|
||||||
|
canLoadMore = false;
|
||||||
|
filter = {
|
||||||
|
course: this.allCourses
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private translate: TranslateService, private calendarProvider: AddonCalendarProvider, private navParams: NavParams,
|
||||||
|
private domUtils: CoreDomUtilsProvider, private coursesProvider: CoreCoursesProvider, private utils: CoreUtilsProvider,
|
||||||
|
private calendarHelper: AddonCalendarHelperProvider, private sitesProvider: CoreSitesProvider,
|
||||||
|
private localNotificationsProvider: CoreLocalNotificationsProvider, private popoverCtrl: PopoverController,
|
||||||
|
private eventsProvider: CoreEventsProvider, private navCtrl: NavController) {
|
||||||
|
|
||||||
|
this.siteHomeId = sitesProvider.getCurrentSite().getSiteHomeId();
|
||||||
|
this.notificationsEnabled = localNotificationsProvider.isAvailable();
|
||||||
|
if (this.notificationsEnabled) {
|
||||||
|
// Re-schedule events if default time changes.
|
||||||
|
this.obsDefaultTimeChange = eventsProvider.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => {
|
||||||
|
calendarProvider.scheduleEventsNotifications(this.events);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// @TODO: Split view once single event is done.
|
||||||
|
// let eventId = navParams.get('eventid') || false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View loaded.
|
* View loaded.
|
||||||
*/
|
*/
|
||||||
ionViewDidLoad() {
|
ionViewDidLoad() {
|
||||||
|
this.fetchData().then(() => {
|
||||||
|
|
||||||
|
}).finally(() => {
|
||||||
|
this.eventsLoaded = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all the data required for the view.
|
||||||
|
*
|
||||||
|
* @param {boolean} refresh Empty events array first.
|
||||||
|
*/
|
||||||
|
fetchData(refresh = false) {
|
||||||
|
this.daysLoaded = 0;
|
||||||
|
this.emptyEventsTimes = 0;
|
||||||
|
|
||||||
|
// Load courses for the popover.
|
||||||
|
return this.coursesProvider.getUserCourses(false).then((courses) => {
|
||||||
|
// Add "All courses".
|
||||||
|
courses.unshift(this.allCourses);
|
||||||
|
this.courses = courses;
|
||||||
|
return this.fetchEvents(refresh);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the events and updates the view.
|
||||||
|
*
|
||||||
|
* @param {boolean} refresh Empty events array first.
|
||||||
|
*/
|
||||||
|
fetchEvents(refresh = false) {
|
||||||
|
return this.calendarProvider.getEventsList(this.daysLoaded, AddonCalendarProvider.DAYS_INTERVAL).then((events) => {
|
||||||
|
this.daysLoaded += AddonCalendarProvider.DAYS_INTERVAL;
|
||||||
|
if (events.length === 0) {
|
||||||
|
this.emptyEventsTimes++;
|
||||||
|
if (this.emptyEventsTimes > 5) { // Stop execution if we retrieve empty list 6 consecutive times.
|
||||||
|
this.canLoadMore = false;
|
||||||
|
if (refresh) {
|
||||||
|
this.events = [];
|
||||||
|
this.filteredEvents = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No events returned, load next events.
|
||||||
|
return this.fetchEvents();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Sort the events by timestart, they're ordered by id.
|
||||||
|
events.sort((a, b) => {
|
||||||
|
return a.timestart - b.timestart;
|
||||||
|
});
|
||||||
|
|
||||||
|
events.forEach(this.calendarHelper.formatEventData);
|
||||||
|
this.getCategories = this.shouldLoadCategories(events);
|
||||||
|
|
||||||
|
if (refresh) {
|
||||||
|
this.events = events;
|
||||||
|
} else {
|
||||||
|
// Filter events with same ID. Repeated events are returned once per WS call, show them only once.
|
||||||
|
this.events = this.utils.mergeArraysWithoutDuplicates(this.events, events, 'id');
|
||||||
|
}
|
||||||
|
this.filteredEvents = this.getFilteredEvents();
|
||||||
|
this.canLoadMore = true;
|
||||||
|
|
||||||
|
// Schedule notifications for the events retrieved (might have new events).
|
||||||
|
this.calendarProvider.scheduleEventsNotifications(this.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize the content so infinite loading is able to calculate if it should load more items or not.
|
||||||
|
// @TODO: Infinite loading is not working if content is not high enough.
|
||||||
|
this.content.resize();
|
||||||
|
}).catch((error) => {
|
||||||
|
this.domUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
|
||||||
|
this.canLoadMore = false; // Set to false to prevent infinite calls with infinite-loading.
|
||||||
|
}).then(() => {
|
||||||
|
// Success retrieving events. Get categories if needed.
|
||||||
|
if (this.getCategories) {
|
||||||
|
this.getCategories = false;
|
||||||
|
return this.loadCategories();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filtered events.
|
||||||
|
*/
|
||||||
|
protected getFilteredEvents() {
|
||||||
|
if (this.filter.course.id == -1) {
|
||||||
|
// No filter, display everything.
|
||||||
|
return this.events;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.events.filter(this.shouldDisplayEvent.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an event should be displayed based on the filter.
|
||||||
|
*
|
||||||
|
* @param {any} event Event object.
|
||||||
|
*/
|
||||||
|
protected shouldDisplayEvent(event: any) {
|
||||||
|
if (event.eventtype == 'user' || event.eventtype == 'site') {
|
||||||
|
// User or site event, display it.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.eventtype == 'category') {
|
||||||
|
if (!event.categoryid || !Object.keys(this.categories).length) {
|
||||||
|
// We can't tell if the course belongs to the category, display them all.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.categoryid == this.filter.course.category) {
|
||||||
|
// The event is in the same category as the course, display it.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check parent categories.
|
||||||
|
let category = this.categories[this.filter.course.category];
|
||||||
|
while (category) {
|
||||||
|
if (!category.parent) {
|
||||||
|
// Category doesn't have parent, stop.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.categoryid == category.parent) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
category = this.categories[category.parent];
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the event if it is from site home or if it matches the selected course.
|
||||||
|
return event.courseid === this.siteHomeId || event.courseid == this.filter.course.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if the current state should load categories or not.
|
||||||
|
* @param {any[]} events Events to parse.
|
||||||
|
* @return {boolean} True if categories should be loaded.
|
||||||
|
*/
|
||||||
|
protected shouldLoadCategories(events: any[]) : boolean {
|
||||||
|
if (this.categoriesRetrieved || this.getCategories) {
|
||||||
|
// Use previous value
|
||||||
|
return this.getCategories;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories not loaded yet. We should get them if there's any category event.
|
||||||
|
let found = events.some(event => event.categoryid != 'undefined' && event.categoryid > 0);
|
||||||
|
return found || this.getCategories;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load categories to be able to filter events.
|
||||||
|
*/
|
||||||
|
protected loadCategories() {
|
||||||
|
return this.coursesProvider.getCategories(0, true).then((cats) => {
|
||||||
|
this.categoriesRetrieved = true;
|
||||||
|
this.categories = {};
|
||||||
|
// Index categories by ID.
|
||||||
|
cats.forEach(function(category) {
|
||||||
|
this.categories[category.id] = category;
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
// Ignore errors.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the events.
|
||||||
|
*
|
||||||
|
* @param {any} refresher Refresher.
|
||||||
|
*/
|
||||||
|
refreshEvents(refresher: any) {
|
||||||
|
let promises = [];
|
||||||
|
|
||||||
|
promises.push(this.calendarProvider.invalidateEventsList(this.courses));
|
||||||
|
|
||||||
|
if (this.categoriesRetrieved) {
|
||||||
|
promises.push(this.coursesProvider.invalidateCategories(0, true));
|
||||||
|
this.categoriesRetrieved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(promises).finally(() => {
|
||||||
|
this.fetchData(true).finally(() => {
|
||||||
|
refresher.complete();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the context menu.
|
||||||
|
*
|
||||||
|
* @param {MouseEvent} event Event.
|
||||||
|
*/
|
||||||
|
openCourseFilter(event: MouseEvent) : void {
|
||||||
|
let popover = this.popoverCtrl.create(CoreCoursePickerMenuPopoverComponent, {courses: this.courses,
|
||||||
|
courseId: this.filter.course.id});
|
||||||
|
popover.onDidDismiss(course => {
|
||||||
|
if (course) {
|
||||||
|
this.filter.course = course;
|
||||||
|
this.content.scrollToTop();
|
||||||
|
this.filteredEvents = this.getFilteredEvents();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
popover.present({
|
||||||
|
ev: event
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open calendar events settings.
|
||||||
|
*/
|
||||||
|
openSettings() {
|
||||||
|
// @TODO: Check the settings page name.
|
||||||
|
this.navCtrl.push('AddonCalendarSettingsPage');
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page destroyed.
|
* Page destroyed.
|
||||||
*/
|
*/
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
this.obsDefaultTimeChange && this.obsDefaultTimeChange.off && this.obsDefaultTimeChange.off();
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -16,16 +16,253 @@ import { Injectable } from '@angular/core';
|
||||||
import { CoreLoggerProvider } from '../../../providers/logger';
|
import { CoreLoggerProvider } from '../../../providers/logger';
|
||||||
import { CoreSitesProvider } from '../../../providers/sites';
|
import { CoreSitesProvider } from '../../../providers/sites';
|
||||||
import { CoreSite } from '../../../classes/site';
|
import { CoreSite } from '../../../classes/site';
|
||||||
|
import { CoreCoursesProvider } from '../../../core/courses/providers/courses';
|
||||||
|
import { CoreTimeUtilsProvider } from '../../../providers/utils/time';
|
||||||
|
import { CoreGroupsProvider } from '../../../providers/groups';
|
||||||
|
import { CoreConstants } from '../../../core/constants';
|
||||||
|
import { CoreLocalNotificationsProvider } from '../../../providers/local-notifications';
|
||||||
|
import { CoreConfigProvider } from '../../../providers/config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service that provides some features regarding lists of courses and categories.
|
* Service to handle calendar events.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AddonCalendarProvider {
|
export class AddonCalendarProvider {
|
||||||
|
public static DAYS_INTERVAL = 30;
|
||||||
|
public static DEFAULT_NOTIFICATION_TIME_CHANGED = 'AddonCalendarDefaultNotificationTimeChangedEvent';
|
||||||
|
protected static DEFAULT_NOTIFICATION_TIME_SETTING = 'AddonCalendarDefaultNotifTime';
|
||||||
|
protected static DEFAULT_NOTIFICATION_TIME = 60;
|
||||||
|
protected static COMPONENT = 'AddonCalendarEvents';
|
||||||
|
|
||||||
|
// Variables for database.
|
||||||
|
protected static EVENTS_TABLE = 'calendar_events'; // Queue of files to download.
|
||||||
|
protected static tablesSchema = [
|
||||||
|
{
|
||||||
|
name: AddonCalendarProvider.EVENTS_TABLE,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'INTEGER',
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'notificationtime',
|
||||||
|
type: 'INTEGER'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'TEXT',
|
||||||
|
notNull: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
type: 'TEXT'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'eventtype',
|
||||||
|
type: 'TEXT'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'courseid',
|
||||||
|
type: 'INTEGER'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'timestart',
|
||||||
|
type: 'INTEGER'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'timeduration',
|
||||||
|
type: 'INTEGER'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'categoryid',
|
||||||
|
type: 'INTEGER'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'groupid',
|
||||||
|
type: 'INTEGER'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'instance',
|
||||||
|
type: 'INTEGER'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'modulename',
|
||||||
|
type: 'TEXT'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'timemodified',
|
||||||
|
type: 'INTEGER'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'repeatid',
|
||||||
|
type: 'INTEGER'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
protected logger;
|
protected logger;
|
||||||
|
|
||||||
constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider) {
|
constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private groupsProvider: CoreGroupsProvider,
|
||||||
|
private coursesProvider: CoreCoursesProvider, private timeUtils: CoreTimeUtilsProvider,
|
||||||
|
private localNotificationsProvider: CoreLocalNotificationsProvider, private configProvider: CoreConfigProvider) {
|
||||||
this.logger = logger.getInstance('AddonCalendarProvider');
|
this.logger = logger.getInstance('AddonCalendarProvider');
|
||||||
|
this.sitesProvider.createTablesFromSchema(AddonCalendarProvider.tablesSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the configured default notification time.
|
||||||
|
*
|
||||||
|
* @param {string} [siteId] ID of the site. If not defined, use current site.
|
||||||
|
* @return {Promise<number>} Promise resolved with the default time.
|
||||||
|
*/
|
||||||
|
getDefaultNotificationTime(siteId?: string) : Promise<number> {
|
||||||
|
siteId = siteId || this.sitesProvider.getCurrentSiteId();
|
||||||
|
|
||||||
|
let key = AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_SETTING + '#' + siteId;
|
||||||
|
return this.configProvider.get(key, AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a calendar event from local Db.
|
||||||
|
*
|
||||||
|
* @param {number} id Event ID.
|
||||||
|
* @param {string} [siteId] ID of the site the event belongs to. If not defined, use current site.
|
||||||
|
* @return {Promise<any>} Promise resolved when the event data is retrieved.
|
||||||
|
*/
|
||||||
|
getEventFromLocalDb(id: number, siteId?: string) : Promise<any> {
|
||||||
|
return this.sitesProvider.getSite(siteId).then((site) => {
|
||||||
|
return site.getDb().getRecord(AddonCalendarProvider.EVENTS_TABLE, {id: id});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event notification time. Always returns number of minutes (0 if disabled).
|
||||||
|
*
|
||||||
|
* @param {number} id Event ID.
|
||||||
|
* @param {string} [siteId] ID of the site the event belongs to. If not defined, use current site.
|
||||||
|
* @return {Promise<number>} Event notification time in minutes. 0 if disabled.
|
||||||
|
*/
|
||||||
|
getEventNotificationTime(id: number, siteId?: string) : Promise<number> {
|
||||||
|
siteId = siteId || this.sitesProvider.getCurrentSiteId();
|
||||||
|
|
||||||
|
return this.getEventNotificationTimeOption(id, siteId).then((time: number) => {
|
||||||
|
if (time == -1) {
|
||||||
|
return this.getDefaultNotificationTime(siteId);
|
||||||
|
}
|
||||||
|
return time;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event notification time for options. Returns -1 for default time.
|
||||||
|
*
|
||||||
|
* @param {number} id Event ID.
|
||||||
|
* @param {string} [siteId] ID of the site the event belongs to. If not defined, use current site.
|
||||||
|
* @return {Promise<number>} Promise with wvent notification time in minutes. 0 if disabled, -1 if default time.
|
||||||
|
*/
|
||||||
|
getEventNotificationTimeOption(id: number, siteId?: string) : Promise<number> {
|
||||||
|
return this.getEventFromLocalDb(id, siteId).then((e) => {
|
||||||
|
return e.notificationtime || -1;
|
||||||
|
}).catch(() => {
|
||||||
|
return -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the events in a certain period. The period is calculated like this:
|
||||||
|
* start time: now + daysToStart
|
||||||
|
* end time: start time + daysInterval
|
||||||
|
* E.g. using provider.getEventsList(30, 30) is going to get the events starting after 30 days from now
|
||||||
|
* and ending before 60 days from now.
|
||||||
|
*
|
||||||
|
* @param {number} [daysToStart=0] Number of days from now to start getting events.
|
||||||
|
* @param {number} [daysInterval=30] Number of days between timestart and timeend.
|
||||||
|
* @param {string} [siteId] Site to get the events from. If not defined, use current site.
|
||||||
|
* @return {Promise<any[]>} Promise to be resolved when the participants are retrieved.
|
||||||
|
*/
|
||||||
|
getEventsList(daysToStart = 0, daysInterval=AddonCalendarProvider.DAYS_INTERVAL, siteId?: string) : Promise<any[]> {
|
||||||
|
return this.sitesProvider.getSite(siteId).then((site) => {
|
||||||
|
siteId = site.getId();
|
||||||
|
|
||||||
|
return this.coursesProvider.getUserCourses(false, siteId).then((courses) => {
|
||||||
|
courses.push({id: site.getSiteHomeId()}); // Add front page.
|
||||||
|
|
||||||
|
return this.groupsProvider.getUserGroups(courses, siteId).then((groups) => {
|
||||||
|
let now = this.timeUtils.timestamp(),
|
||||||
|
start = now + (CoreConstants.secondsDay * daysToStart),
|
||||||
|
end = start + (CoreConstants.secondsDay * daysInterval);
|
||||||
|
|
||||||
|
// The core_calendar_get_calendar_events needs all the current user courses and groups.
|
||||||
|
let data = {
|
||||||
|
"options[userevents]": 1,
|
||||||
|
"options[siteevents]": 1,
|
||||||
|
"options[timestart]": start,
|
||||||
|
"options[timeend]": end
|
||||||
|
};
|
||||||
|
|
||||||
|
courses.forEach((course, index) => {
|
||||||
|
data["events[courseids][" + index + "]"] = course.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
groups.forEach((group, index) => {
|
||||||
|
data["events[groupids][" + index + "]"] = group.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// We need to retrieve cached data using cache key because we have timestamp in the params.
|
||||||
|
let preSets = {
|
||||||
|
cacheKey: this.getEventsListCacheKey(daysToStart, daysInterval),
|
||||||
|
getCacheUsingCacheKey: true
|
||||||
|
};
|
||||||
|
|
||||||
|
return site.read('core_calendar_get_calendar_events', data, preSets).then((response) => {
|
||||||
|
this.storeEventsInLocalDB(response.events, siteId);
|
||||||
|
return response.events;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache key for events list WS calls.
|
||||||
|
*
|
||||||
|
* @param {number} daysToStart Number of days from now to start getting events.
|
||||||
|
* @param {number} daysInterval Number of days between timestart and timeend.
|
||||||
|
* @return {string} Cache key.
|
||||||
|
*/
|
||||||
|
protected getEventsListCacheKey(daysToStart: number, daysInterval: number) : string {
|
||||||
|
return this.getRootCacheKey() + 'eventslist:' + daysToStart + ':' + daysInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the root cache key for the WS calls related to this provider.
|
||||||
|
*
|
||||||
|
* @return {string} Root cache key.
|
||||||
|
*/
|
||||||
|
protected getRootCacheKey() : string {
|
||||||
|
return 'mmaCalendar:';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates events list and all the single events and related info.
|
||||||
|
*
|
||||||
|
* @param {any[]} courses List of courses or course ids.
|
||||||
|
* @param {string} [siteId] Site Id. If not defined, use current site.
|
||||||
|
* @return {Promise<any>} Promise resolved when the list is invalidated.
|
||||||
|
*/
|
||||||
|
invalidateEventsList(courses: any[], siteId?: string) {
|
||||||
|
return this.sitesProvider.getSite(siteId).then((site) => {
|
||||||
|
siteId = site.getId();
|
||||||
|
|
||||||
|
let promises = [];
|
||||||
|
|
||||||
|
promises.push(this.coursesProvider.invalidateUserCourses(siteId));
|
||||||
|
promises.push(this.groupsProvider.invalidateUserGroups(courses, siteId));
|
||||||
|
promises.push(site.invalidateWsCacheForKeyStartingWith(this.getRootCacheKey()));
|
||||||
|
return Promise.all(promises);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -39,4 +276,122 @@ export class AddonCalendarProvider {
|
||||||
return site.isFeatureDisabled('$mmSideMenuDelegate_mmaCalendar');
|
return site.isFeatureDisabled('$mmSideMenuDelegate_mmaCalendar');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules an event notification. If time is 0, cancel scheduled notification if any.
|
||||||
|
* If local notification plugin is not enabled, resolve the promise.
|
||||||
|
*
|
||||||
|
* @param {any} event Event to schedule.
|
||||||
|
* @param {number} time Notification setting time (in minutes). E.g. 10 means "notificate 10 minutes before start".
|
||||||
|
* @param {string} [siteId] Site ID the event belongs to. If not defined, use current site.
|
||||||
|
* @return {Promise<void>} Promise resolved when the notification is scheduled.
|
||||||
|
*/
|
||||||
|
scheduleEventNotification(event: any, time: number, siteId?: string) : Promise<void> {
|
||||||
|
if (this.localNotificationsProvider.isAvailable()) {
|
||||||
|
siteId = siteId || this.sitesProvider.getCurrentSiteId();
|
||||||
|
|
||||||
|
if (time === 0) {
|
||||||
|
// Cancel if it was scheduled.
|
||||||
|
return this.localNotificationsProvider.cancel(event.id, AddonCalendarProvider.COMPONENT, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If time is -1, get event default time.
|
||||||
|
let promise = time == -1 ? this.getDefaultNotificationTime(siteId) : Promise.resolve(time);
|
||||||
|
|
||||||
|
return promise.then((time) => {
|
||||||
|
let timeend = (event.timestart + event.timeduration) * 1000;
|
||||||
|
if (timeend <= new Date().getTime()) {
|
||||||
|
// The event has finished already, don't schedule it.
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateTriggered = new Date((event.timestart - (time * 60)) * 1000),
|
||||||
|
startDate = new Date(event.timestart * 1000),
|
||||||
|
notification = {
|
||||||
|
id: event.id,
|
||||||
|
title: event.name,
|
||||||
|
text: startDate.toLocaleString(),
|
||||||
|
at: dateTriggered,
|
||||||
|
data: {
|
||||||
|
eventid: event.id,
|
||||||
|
siteid: siteId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.localNotificationsProvider.schedule(notification, AddonCalendarProvider.COMPONENT, siteId);
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules the notifications for a list of events.
|
||||||
|
* If an event notification time is 0, cancel its scheduled notification (if any).
|
||||||
|
* If local notification plugin is not enabled, resolve the promise.
|
||||||
|
*
|
||||||
|
* @param {any[]} events Events to schedule.
|
||||||
|
* @param {string} [siteId] ID of the site the events belong to. If not defined, use current site.
|
||||||
|
* @return {Promise<any[]>} Promise resolved when all the notifications have been scheduled.
|
||||||
|
*/
|
||||||
|
scheduleEventsNotifications(events: any[], siteId?: string) : Promise<any[]> {
|
||||||
|
var promises = [];
|
||||||
|
|
||||||
|
if (this.localNotificationsProvider.isAvailable()) {
|
||||||
|
siteId = siteId || this.sitesProvider.getCurrentSiteId();
|
||||||
|
events.forEach((e) => {
|
||||||
|
promises.push(this.getEventNotificationTime(e.id, siteId).then((time) => {
|
||||||
|
return this.scheduleEventNotification(e, time, siteId);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store events in local DB.
|
||||||
|
*
|
||||||
|
* @param {any[]} events Events to store.
|
||||||
|
* @param {string} [siteId] ID of the site the event belongs to. If not defined, use current site.
|
||||||
|
* @return {Promise<any[]>} Promise resolved when the events are stored.
|
||||||
|
*/
|
||||||
|
protected storeEventsInLocalDB(events: any[], siteId?: string) : Promise<any[]> {
|
||||||
|
return this.sitesProvider.getSite(siteId).then((site) => {
|
||||||
|
siteId = site.getId();
|
||||||
|
|
||||||
|
let promises = [],
|
||||||
|
db = site.getDb();
|
||||||
|
|
||||||
|
events.forEach((event) => {
|
||||||
|
// Don't override event notification time if the user configured it.
|
||||||
|
promises.push(this.getEventFromLocalDb(event.id, siteId).catch(() => {
|
||||||
|
// Event not stored, return empty object.
|
||||||
|
return {};
|
||||||
|
}).then((e) => {
|
||||||
|
let eventRecord = {
|
||||||
|
id: event.id,
|
||||||
|
name: event.name,
|
||||||
|
description: event.description,
|
||||||
|
eventtype: event.eventtype,
|
||||||
|
courseid: event.courseid,
|
||||||
|
timestart: event.timestart,
|
||||||
|
timeduration: event.timeduration,
|
||||||
|
categoryid: event.categoryid,
|
||||||
|
groupid: event.groupid,
|
||||||
|
instance: event.instance,
|
||||||
|
modulename: event.modulename,
|
||||||
|
timemodified: event.timemodified,
|
||||||
|
repeatid: event.repeatid,
|
||||||
|
notificationtime: e.notificationtime || -1
|
||||||
|
};
|
||||||
|
|
||||||
|
return db.insertOrUpdateRecord(AddonCalendarProvider.EVENTS_TABLE, eventRecord, {id: eventRecord.id});
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
// (C) Copyright 2015 Martin Dougiamas
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CoreLoggerProvider } from '../../../providers/logger';
|
||||||
|
import { CoreSitesProvider } from '../../../providers/sites';
|
||||||
|
//import { CoreCourseProvider } from '../../../core/course/providers/course';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service that provides some features regarding lists of courses and categories.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AddonCalendarHelperProvider {
|
||||||
|
protected logger;
|
||||||
|
|
||||||
|
private static eventicons = {
|
||||||
|
'course': 'ionic',
|
||||||
|
'group': 'people',
|
||||||
|
'site': 'globe',
|
||||||
|
'user': 'person',
|
||||||
|
'category': 'albums'
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider) {
|
||||||
|
this.logger = logger.getInstance('AddonCalendarHelperProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to format some event data to be rendered.
|
||||||
|
*
|
||||||
|
* @param {any} e Event to format.
|
||||||
|
*/
|
||||||
|
formatEventData(e: any) {
|
||||||
|
let icon = AddonCalendarHelperProvider.eventicons[e.eventtype] || false;
|
||||||
|
if (!icon) {
|
||||||
|
// @TODO: It's a module event.
|
||||||
|
//icon = this.courseProvider.getModuleIconSrc(e.modulename);
|
||||||
|
e.moduleicon = icon;
|
||||||
|
}
|
||||||
|
e.icon = icon;
|
||||||
|
};
|
||||||
|
}
|
|
@ -802,8 +802,8 @@ export class CoreSite {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug('Invalidate cache for key starting with: ' + key);
|
this.logger.debug('Invalidate cache for key starting with: ' + key);
|
||||||
let sql = 'UPDATE ' + this.WS_CACHE_TABLE + ' SET expirationTime=0 WHERE key LIKE ?%';
|
let sql = 'UPDATE ' + this.WS_CACHE_TABLE + ' SET expirationTime=0 WHERE key LIKE ?';
|
||||||
return this.db.execute(sql, [key]);
|
return this.db.execute(sql, [key + "%"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -29,6 +29,7 @@ import { CoreFileComponent } from './file/file';
|
||||||
import { CoreContextMenuComponent } from './context-menu/context-menu';
|
import { CoreContextMenuComponent } from './context-menu/context-menu';
|
||||||
import { CoreContextMenuItemComponent } from './context-menu/context-menu-item';
|
import { CoreContextMenuItemComponent } from './context-menu/context-menu-item';
|
||||||
import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-popover';
|
import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-popover';
|
||||||
|
import { CoreCoursePickerMenuPopoverComponent } from './course-picker-menu/course-picker-menu-popover';
|
||||||
import { CoreChronoComponent } from './chrono/chrono';
|
import { CoreChronoComponent } from './chrono/chrono';
|
||||||
import { CoreLocalFileComponent } from './local-file/local-file';
|
import { CoreLocalFileComponent } from './local-file/local-file';
|
||||||
import { CoreSitePickerComponent } from './site-picker/site-picker';
|
import { CoreSitePickerComponent } from './site-picker/site-picker';
|
||||||
|
@ -47,12 +48,14 @@ import { CoreSitePickerComponent } from './site-picker/site-picker';
|
||||||
CoreContextMenuComponent,
|
CoreContextMenuComponent,
|
||||||
CoreContextMenuItemComponent,
|
CoreContextMenuItemComponent,
|
||||||
CoreContextMenuPopoverComponent,
|
CoreContextMenuPopoverComponent,
|
||||||
|
CoreCoursePickerMenuPopoverComponent,
|
||||||
CoreChronoComponent,
|
CoreChronoComponent,
|
||||||
CoreLocalFileComponent,
|
CoreLocalFileComponent,
|
||||||
CoreSitePickerComponent
|
CoreSitePickerComponent
|
||||||
],
|
],
|
||||||
entryComponents: [
|
entryComponents: [
|
||||||
CoreContextMenuPopoverComponent
|
CoreContextMenuPopoverComponent,
|
||||||
|
CoreCoursePickerMenuPopoverComponent
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
IonicModule,
|
IonicModule,
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
<ion-list radio-group [(ngModel)]="courseId">
|
||||||
|
<ion-item text-wrap *ngFor="let course of courses" >
|
||||||
|
<ion-label><core-format-text [text]="course.fullname"></core-format-text></ion-label>
|
||||||
|
<ion-radio value="{{course.id}}" (ionSelect)="coursePicked($event, course)" ></ion-radio>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
|
@ -0,0 +1,45 @@
|
||||||
|
// (C) Copyright 2015 Martin Dougiamas
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { NavParams, ViewController } from 'ionic-angular';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to display a list of courses.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'core-course-picker-menu-popover',
|
||||||
|
templateUrl: 'course-picker-menu-popover.html'
|
||||||
|
})
|
||||||
|
export class CoreCoursePickerMenuPopoverComponent {
|
||||||
|
courses: any[];
|
||||||
|
courseId = -1;
|
||||||
|
|
||||||
|
constructor(private navParams: NavParams, private viewCtrl: ViewController) {
|
||||||
|
this.courses = navParams.get('courses') || [];
|
||||||
|
this.courseId = navParams.get('courseId') || -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function called when a course is clicked.
|
||||||
|
*
|
||||||
|
* @param {Event} event Click event.
|
||||||
|
* @param {any} course Course object clicked.
|
||||||
|
* @return {boolean} Return true if success, false if error.
|
||||||
|
*/
|
||||||
|
coursePicked(event: Event, course: any) : boolean {
|
||||||
|
this.viewCtrl.dismiss(course);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
|
@ -739,7 +739,7 @@ export class CoreSitesProvider {
|
||||||
* @param {number} [siteId] The site ID. If not defined, current site (if available).
|
* @param {number} [siteId] The site ID. If not defined, current site (if available).
|
||||||
* @return {Promise} Promise resolved with site home ID.
|
* @return {Promise} Promise resolved with site home ID.
|
||||||
*/
|
*/
|
||||||
getSiteHomeId(siteId: string) : Promise<number> {
|
getSiteHomeId(siteId?: string) : Promise<number> {
|
||||||
return this.getSite(siteId).then((site) => {
|
return this.getSite(siteId).then((site) => {
|
||||||
return site.getSiteHomeId();
|
return site.getSiteHomeId();
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue