I encountered a requirement while implementing a mobile application recently where I needed to display events in a mobile application that are published on a Google Calendar. I could have gone the route of using a WebView and embedding it that way, but I wanted the users to experience the richness and beauty of using a fully native application.
I decided to utilize the UI for NativeScript from Progress Software, a professional grade component library that is available free of charge. Included in this offering is a Calendar component that satisfies my need for a better user experience.
Note: this is expecting that you already have the module importing the UI for NativeScript Calendar and ListView components. It is also importing only basic iCal data, no recurrence rule parsing is taking place. This article also doesn’t step you through creating a NativeScript application from scratch, it shows you how to add the functionality into an existing module. In this case, I have created an application module called Calendar, and I have already setup the module, routes, etc.
Obtain the calendar feed
The first thing that you will need is the public or private feed for the Google Calendar that you’d like to have imported to have displayed in the NativeScript Calendar component. To do this, open your Google Calendar page, on the upper-right hand corner of the page, click on cog icon and select ‘Settings’.
Next, ensure you select the correct calendar to edit the settings for. Then scroll toward the bottom of the settings window, and find the iCal address. You can choose a public link (for published calendars) or the private link for private/unpublished calendars. In my case, the calendar is not public, so I am making a note of the private iCal address.
Establishing a model
In order to populate the Calendar component, it requires data to adhere to the CalendarEvent model established in the API. This model includes the following properties:
- endDate
- eventColor
- isAllDay
- startDate
- title
This is a great model for a base calendar, however, I need a little more information captured in my events, so I created a new class that extends the CalendarEvent model – adding to it a couple more properties. Here is a listing of my app/models/calendar-event-extended.ts file:
import { CalendarEvent } from "nativescript-ui-calendar";
import { Color } from "tns-core-modules/color/color";
export class CalendarEventExtended extends CalendarEvent {
description: string;
location: string;
constructor() {
const maxDate = new Date(8640000000000000);
const minDate = new Date(-8640000000000000);
super("None", minDate, maxDate, false, new Color(200, 188, 26, 214));
this.description = "";
this.location = "";
}
}
Creating a service class
I have my project setup to have service classes to retrieve data from Firebase Firestore or any other external data source. This is where I will put my class that is responsible for pulling, parsing and transforming the iCal data into the CalendarEventExtended structure. Here is a listing of my services/calendar-service.ts file:
import * as moment from "moment";
import { getString } from "tns-core-modules/http";
import traceModule = require("tns-core-modules/trace");
import { CalendarEventExtended } from "../models/calendar-event-extended";
export class CalendarService {
private _icsUrl: string = "";
constructor(icsUrl: string) {
this._icsUrl = icsUrl;
}
getCalendarEventsPromise(): Promise<Array<CalendarEventExtended>> {
return new Promise<Array<CalendarEventExtended>>((resolve, reject) => {
getString(this._icsUrl).then((content) => {
const calEvents = new Array<CalendarEventExtended>();
const lines = content.split("\n");
let entry = new CalendarEventExtended();
lines.forEach((itm) => {
if (itm.indexOf("BEGIN:VEVENT") === 0) {
entry = new CalendarEventExtended();
} else if (itm.indexOf("DTSTART") === 0) {
if (itm.indexOf("VALUE=DATE") >= 0) {
entry.isAllDay = true;
entry.startDate = this._getDateOnlyFromIcalString(itm);
} else {
entry.startDate = this._getDateTimeFromIcalString(itm);
}
} else if (itm.indexOf("DTEND") === 0) {
entry.endDate = (itm.indexOf("VALUE=DATE") >= 0) ?
this._getDateOnlyFromIcalString(itm) :
this._getDateTimeFromIcalString(itm);
} else if (itm.indexOf("SUMMARY") === 0) {
entry.title = this._getTextFromIcalString(itm);
} else if (itm.indexOf("DESCRIPTION") === 0) {
entry.description = this._getTextFromIcalString(itm);
} else if (itm.indexOf("LOCATION") === 0) {
entry.location = this._getTextFromIcalString(itm);
} else if (itm.indexOf("END:VEVENT") === 0) {
calEvents.push(entry);
}
});
resolve(calEvents);
}, (err) => {
traceModule.error(err);
reject(err);
});
});
}
private _getDateTimeFromIcalString(str: string): Date {
const dteNum = str.split(":")[1];
const m = moment(dteNum, "YYYYMMDDTHHmmssZ");
return m.toDate();
}
private _getDateOnlyFromIcalString(str: string): Date {
const dteNum = str.split(":")[1];
const m = moment(dteNum, "YYYYMMDD");
return m.toDate();
}
private _getTextFromIcalString(str: string): string {
const idx = str.indexOf(":");
return str.substr(idx + 1).replace("\\", "");
}
}
From the code above, you can see that I am pulling the iCal data from a url and parsing its content. This is only providing basic parsing, I don’t have logic in there to deal with recurrence rules, so this will work in the basic calendar usage case. I am also utilizing the trace module for logging functionality, so if you are not using that – it can be removed. The getCalendarEventsPromise function returns just that, a promise that it will retrieve, parse, and transform the calendar data into an array of CalendarEventExtended objects.
Establishing a View Model
In this application, what I needed to do is display the calendar with events, then when a date is selected, to list the events for that date in a list below the calendar (I am also using the ListView component from UI from NativeScript). This View Model is also responsible for setting styles for the calendar, most of which I retained as the defaults. You will notice that I am importing another service, application-service – this is where I have set things like the iCal url as well as the color palette for the application. The listing for view-models/calendar-view-model.ts is as follows:
import { Observable } from "data/observable";
import { ObservableArray } from "data/observable-array";
import * as Moment from "moment";
import * as MomentRange from "moment-range";
import { CalendarDayViewStyle, CalendarEventsViewMode, CalendarMonthNamesViewStyle, CalendarMonthViewStyle, CalendarSelectionEventData, CalendarSelectionMode, CalendarTransitionMode, CalendarViewMode, CalendarWeekViewStyle, CalendarYearViewStyle, DayCellStyle, SelectionShape } from "nativescript-ui-calendar";
import { CalendarEventExtended } from "../models/calendar-event-extended";
import { applicationService } from "../services/application-service";
const moment = MomentRange.extendMoment(Moment);
export class CalendarViewModel extends Observable {
private _calendarEvents: ObservableArray<CalendarEventExtended> = new ObservableArray<CalendarEventExtended>();
private _calendarEventsFiltered: ObservableArray<CalendarEventExtended> = new ObservableArray<CalendarEventExtended>();
private _selectedDate: Date = new Date();
constructor() {
super();
}
get selectedDate(): Date {
return this._selectedDate;
}
set selectedDate(dte: Date) {
this._selectedDate = dte;
}
get calendarEvents(): ObservableArray<CalendarEventExtended> {
return this._calendarEvents;
}
get calendarEventsFiltered(): ObservableArray<CalendarEventExtended> {
return this._calendarEventsFiltered;
}
updateCalendarEventsFiltered(events: Array<CalendarEventExtended>) {
this.calendarEventsFiltered.length = 0;
if (events && events.length) {
for (const ce of events) {
this.calendarEventsFiltered.push(ce);
}
}
}
updateCalendarEvents(list: Array<CalendarEventExtended>) {
this.calendarEvents.length = 0;
if (list && list.length) {
for (const ce of list) {
this.calendarEvents.push(ce);
}
}
}
onDateSelected(args: CalendarSelectionEventData) {
this.calendarEventsFiltered.length = 0;
if (args.date) {
const filtered = this.calendarEvents.filter((itm, idx, calendarEvents) => {
const range = moment().range(moment(itm.startDate).startOf("day"), moment(itm.endDate).endOf("day"));
return range.contains(args.date);
});
this.updateCalendarEventsFiltered(filtered);
}
}
get selectionMode() {
return CalendarSelectionMode.Single;
}
get viewMode() {
return CalendarViewMode.Month;
}
get transitionMode() {
return CalendarTransitionMode.Slide;
}
get eventsViewMode() {
return CalendarEventsViewMode.None;
}
get monthViewStyle(): CalendarMonthViewStyle {
const monthViewStyle = new CalendarMonthViewStyle();
monthViewStyle.backgroundColor = applicationService.iconsTextColorHex;
monthViewStyle.showTitle = true;
monthViewStyle.showWeekNumbers = false;
monthViewStyle.showDayNames = true;
monthViewStyle.selectionShape = SelectionShape.None;
const todayCellStyle = new DayCellStyle();
todayCellStyle.cellBackgroundColor = applicationService.primaryColorHex;
todayCellStyle.cellBorderWidth = 2;
todayCellStyle.cellBorderColor = applicationService.primaryColorHex;
monthViewStyle.todayCellStyle = todayCellStyle;
const dayCellStyle = new DayCellStyle();
dayCellStyle.eventTextSize = 4; monthViewStyle.dayCellStyle = dayCellStyle;
const weekendCellStyle = new DayCellStyle();
weekendCellStyle.cellBackgroundColor = applicationService.extraLightPrimaryColorHex;
monthViewStyle.weekendCellStyle = weekendCellStyle;
const selectedCellStyle = new DayCellStyle();
selectedCellStyle.cellBackgroundColor = applicationService.accentColorHex;
monthViewStyle.selectedDayCellStyle = selectedCellStyle;
return monthViewStyle;
}
get weekViewStyle(): CalendarWeekViewStyle {
const weekViewStyle = new CalendarWeekViewStyle();
return weekViewStyle;
}
get yearViewStyle(): CalendarYearViewStyle {
const yearViewStyle = new CalendarYearViewStyle();
return yearViewStyle;
}
get dayViewStyle(): CalendarDayViewStyle {
const dayViewStyle = new CalendarDayViewStyle();
return dayViewStyle;
}
get monthNamesViewStyle(): CalendarMonthNamesViewStyle {
const monthNamesViewStyle = new CalendarMonthNamesViewStyle();
return monthNamesViewStyle;
}
}
From the code above, you can see that I am using MomentJS and the extensions available in Moment-Range packages – both are available via npm. You can also see that I’ve created an event onDateSelected that will filter events by the date selected – these will be displayed in the ListView.
Date Formatting Display Pipe
In my list view, I also wanted to format the dates and times in a specific format, as such I created a simple DateTimeFormatter pipe to transform my data to the desired format. This formatter must be in your @NgModule declaration in order to use it. The source code for the pipes/dateTimeFormatter.ts is as follows:
import { Pipe, PipeTransform } from "@angular/core";
import * as moment from "moment";
@Pipe({
name: "sgDateTimeFormatter",
pure: true
})
export class DateTimeFormatter implements PipeTransform {
transform(dte: Date): string {
return moment(dte).format("MM/DD/YYYY h:mm A");
}
}
Finally – the view!
The component code for my application Calendar module utilizes another plug-in for displaying the events list as a series of cards. I am also using the angular drawer navigation template as the basis of my application. My component source file listing is as follows (app/calendar/calendar.component.ts):
import { AfterViewInit, Component, OnInit } from "@angular/core";
import * as app from "application";
import { registerElement } from "nativescript-angular/element-registry";
import { CardView } from "nativescript-cardview";
import { RadSideDrawer } from "nativescript-ui-sidedrawer";
import { applicationService } from "../services/application-service";
import { CalendarService } from "../services/calendar-service";
import { CalendarViewModel } from "../view-models/calendar-view-model";
registerElement("CardView", () => CardView);
@Component({
selector: "Calendar",
moduleId: module.id,
templateUrl: "./calendar.component.html"
})
export class CalendarComponent implements OnInit, AfterViewInit {
vm: CalendarViewModel = new CalendarViewModel();
constructor() {
// Use the component constructor to inject providers.
}
ngOnInit(): void {
// Init your component properties here.
}
ngAfterViewInit(): void {
const svc = new CalendarService(applicationService.calendarUrl);
svc.getCalendarEventsPromise().then((result) => {
this.vm.updateCalendarEvents(result);
});
}
onDrawerButtonTap(): void {
const sideDrawer = <RadSideDrawer>app.getRootView();
sideDrawer.showDrawer();
}
}
From the code above, you can see that it utilizes the service that was created to pull back data and populate the view model.
The view listing is as follows (calendar.component.html):
<ActionBar class="action-bar">
<!--
Use the NavigationButton as a side-drawer button in Android
because ActionItems are shown on the right side of the ActionBar
-->
<NavigationButton ios:visibility="collapsed" icon="res://menu" (tap)="onDrawerButtonTap()"></NavigationButton>
<!--
Use the ActionItem for IOS with position set to left. Using the
NavigationButton as a side-drawer button in iOS is not possible,
because its function is to always navigate back in the application.
-->
<ActionItem icon="res://navigation/menu" android:visibility="collapsed" (tap)="onDrawerButtonTap()"
ios.position="left">
</ActionItem>
<Label class="action-bar-title" text="Calendar"></Label>
</ActionBar>
<GridLayout rows="2*,*" columns="*" class="page page-content">
<RadCalendar [eventSource]="vm.calendarEvents"
[monthViewStyle] = "vm.monthViewStyle" [weekViewStyle]="vm.weekViewStyle"
[monthNamesViewStyle] = "vm.monthNamesViewStyle" [yearViewStyle]="vm.yearViewStyle"
[dayViewStyle]="vm.dayViewStyle" [transitionMode]="vm.transitionMode"
[selectionMode]="vm.selectionMode" [eventsViewMode]="vm.eventsViewMode"
[selectedDate]="vm.defaultDate"
(dateSelected)="vm.onDateSelected($event)"
row="0" col="0">
</RadCalendar>
<RadListView [items]="vm.calendarEventsFiltered" row="1" col="0">
<ng-template tkListItemTemplate let-item="item">
<CardView class="cardStyle" elevation="10" radius="1">
<StackLayout orientation="vertical">
<Label class="primary-header" [text]="item.title"></Label>
<StackLayout class="list-item-divider"></StackLayout>
<Label class="secondary-header" [text]="item.description"></Label>
<StackLayout margin="5" *ngIf="!item.isAllDay">
<Label text="Start Date: {{item.startDate | sgDateTimeFormatter}}"></Label>
<Label text="End Date: {{item.endDate | sgDateTimeFormatter}}"></Label>
</StackLayout>
<StackLayout margin="5" *ngIf="item.isAllDay">
<Label text="Date: {{item.startDate | sgDateTimeFormatter}}"></Label>
</StackLayout>
</StackLayout>
</CardView>
</ng-template>
</RadListView>
</GridLayout>
Here is what the resulting code looks like on a device:
Wrap-up
One of the nice things about NativeScript is its flexibility. I like that I am easily able to extend it using JavaScript and leverage many of the helper libraries available on NPM to get me where I need to go. This is just a simple example of how you can provide an in-application native experience to your users while still using information you’ve already got published on the web.
Thank you for this