This is a continuation of a previous tutorial.
I've got two kids with a different school plans. We're using iOS and Android devices to follow them. Others have seen the benefits and would like to have such apps as well.
A mobile application which will:
(Copied from previous chapter)
- Display school plan(s).
- Work offline
- Work on many platforms
(New goal)
- Download school plans from a server
- Creating apps using Cordova.
- Mozilla Brick
Please read instructions how to load any stage in this tutorial.
I assume Cordova and Bower are installed and working
See code on GitHub
I call it a stage 5 because it really is a continuation.
- Modify the school plan app to work with data instead of the plain HTML
Remove old data from www/index.html
leaving only the minimal structure. Certainly this also could be created using JavaScript, but I think it's better to have some HTML.
<brick-tabbar id="plan-group-menu">
</brick-tabbar>
<brick-deck id="plan-group">
</brick-deck>
To prepare for data being pulled from another system there is a need to have a way to read them. Most common data structure used in JavaScript project is JSON.
For now it's OK to distribute the school plan together with the app, It's gonna be much easier to modify it for others as well, as editing data file is simple.
I've placed the data in www/data/plans.json
file. It contains an Array
of plans. Plan is an Object
containing title
, id
optional active
field and weeek
, an actual plan which is an Array
of String
s. (Note: use jshint to check the quality of your code)
[
{
"title": "Name of the plan",
"id": "id-of-the-plan",
"active": 1, // should the plan be the active one?
"week": [
[
"first hour",
"second hour",
"third",
// ... and so on
[], // no activities on Wednesday
], [
"",
"",
"starting on third",
"fourth"
]
}
]
Now there is a need to read this JSON file from the app's directory.
Previously I planned to use Cordova's FileSystem plugin, but it only made the code more complicated.
As both cards and tabs do not exist at the moment of loading the JavaScript file I've removed from www/js/index.js
part where these were linked together.
I'm using a standard XMLHttpRequest
var request = new XMLHttpRequest();
request.onload = app.renderData;
request.open("get", "app_data/plans.json", true);
request.send();
Where app.renderData
parses the JSON and sends data to app.createUI
so UI will be created on its basis.
renderData: function() {
var plans = [];
try {
plans = JSON.parse(this.responseText);
} catch(e) {}
app.createUI(plans);
},
To create the UI we will need weekday names. The best option is to use Cordova's Globalization plugin.
cordova plugin add org.apache.cordova.globalization
Week days are retrieved using navigator.globalization.getDateNames
:
createUI: function(plans) {
var deck = document.getElementById('plan-group');
var tabbar = document.getElementById('plan-group-menu');
navigator.globalization.getDateNames(function(dayOfWeek){
// render UI
}, function() {}, {type: 'narrow', item: 'days'});
dayOfWeek
will hold an Array
of week day names. In my case ['Pn', 'Wt', 'Śr', 'Cz', 'Pt']
. If you'd like to use full day names, just change type: 'narrow'
to type: 'wide'
.
Now there is a need to create individual plans. This time brick-tabbar-tab
and brick-card
elements are created using JavaScript. Tab refers to the right card using its target
parameter. It has the same value as card's id
. Brick will parse this value and create a tab.targetElement
which will link to the card element.
for (var i = 0; i < plans.length; i++) {
var plan = plans[i];
// create card
var card = document.createElement('brick-card');
card.setAttribute('id', plan.id);
deck.appendChild(card);
//create tab
var tab = document.createElement('brick-tabbar-tab');
tab.setAttribute('target', plan.id);
tab.appendChild(document.createTextNode(plan.title));
tabbar.appendChild(tab);
// link card to tab
card.tabElement = tab;
card.addEventListener('show', function() {
this.tabElement.select();
});
// create plan table
var table = document.createElement('table');
// ...
}
First we will create actual plan elements and then the header. It is because table.insertRow()
either creates a new tbody
and tr
inside or adds a row to any existing HTMLTableSectionElement
(thead
if already created). We could call table.tBodies(0)
but it would complicate the code.
There is a problem to be solved - we're representing the plan in data as we understood it - hours inside days. Unfortunately tables in HTML are created row by row (days inside hours) which means the array needs to be rotated
var daysInHours = [];
for (var j = 0; j < plan.week.length; j++) {
for (var k = 0; k < plan.week[j].length; k++) {
if (!daysInHours[k]) {
daysInHours[k] = [];
}
daysInHours[k][j] = plan.week[j][k];
}
}
Now daysInHours
array can be easily used to render the plan into HTML table. There is an important note - table.insertRow
needs to use the optional index, as by default Android inserts the row on top:
for (var j = 0; j < daysInHours.length; j++) {
var tr = table.insertRow(-1);
var td = tr.insertCell();
td.appendChild(document.createTextNode(j + 1));
for (var k = 0; k < daysInHours[j].length; k++) {
var td = tr.insertCell();
if (daysInHours[j][k]) {
td.appendChild(document.createTextNode(daysInHours[j][k]));
}
}
}
Table needs a header:
var thead = table.createTHead();
var tr = thead.insertRow();
var th_empty = document.createElement('th');
tr.appendChild(th_empty);
for (var j = 0; j < daysInHours.length; j++) {
// add th only if day isn't empty
if (plan.week[j]) {
var th = document.createElement('th');
th.appendChild(document.createTextNode(dayOfWeek.value[j]));
tr.appendChild(th);
}
}
Table needs to be placed inside brick-card
element:
card.appendChild(table);
The right tab needs to be selected when the app is loaded:
if (plan.active) {
var activeTab = tab;
window.setTimeout(function() {activeTab.select()}, 0);
}
The result is almost the same as in Stage4. The only difference being short weekday names.
Short after I've seen the app working I've found few issues:
- If the last days are shorter than the first ones
td
element is not created and background is not visible. - The second comes from globalization. I started the week with Monday and I was collecting it's name from weekday array using index 0. It's fine for Polish calendar, but not so good for English one.
- Empty day was displayed. This would be bad for students who attend to the school at the weekend
- On Android the first
card
is not displayed "on load", one needs to slide the card to make it visible
I decided to use navigator.globalization.getFirstDayOfWeek
and on the basis of its result shift the days. I'm counting the days from Monday as it's the most common case at schools.
navigator.globalization.getDateNames(function(dayOfWeek){
navigator.globalization.getFirstDayOfWeek(function(firstDay){
var dayShift = (1 - firstDay.value) % 7;
and instead of using the index I shifted it by dayShift
when displaying th
elements:
th.appendChild(document.createTextNode(dayOfWeek.value[j + dayShift]));
This is divided into two sections. First the header. I was checking if the day is empty with
if (plan.week[j]) {
// render the day name
}
Instead I should check if the day's array length is greater than 0:
if (plan.week[j].length > 0) {
// render the day name
}
After we've rendered the header it is not needed to know the week day inside the plan. Only the order is important. So, fixing displaying the actual plan involved deleting empty days from array.
var cleanPlan = [];
for (j = 0; j < numberOfDays; j++) {
if (plan.week[j].length > 0) {
cleanPlan.push(plan.week[j]);
}
}
And using the cleanPlan
instead of plan.week
when rotating the table.
After the table is rotated number of days in hours might be shorter than all days. So when iterating inside the row I decided to use cleanPlan.length
instead of daysInHours[j].length
:
for (var j = 0; j < daysInHours.length; j++) {
var tr = table.insertRow(-1);
// ...
for (var k = 0; k < cleanPlan.length; k++) {
var td = tr.insertCell();
// ...
Seems like the selection of the tabbar
happens before it's been attached to the card
. I've changed
window.setTimeout(function() {activeTab.select()}, 0);
To
if (plan.active) {
selectTab(tab);
}
where selectTab
is using polling for activeTab.targetElement
to detect if Brick already linked tab with cards:
function selectTab(activeTab) {
function selectActiveTab() {
if (!activeTab.targetElement) {
return window.setTimeout(selectActiveTab, 100);
}
deck.showCard(activeTab.targetElement);
}
selectActiveTab();
}
See code on GitHub (app and server)
- Download the plans from the server
We will just download the plans every time the app is used. This is against our plan to make it work offline. This step may not stay as a final one.
Building a server using NodeJS is fairly simple. http
is the only module needed. We will also use fs
to load the file from disk and sys
to display logs.
Create the server is a separate directory as it is not a part of the app. Now move the plans file from application to the server's directory.
mv stage5/app_data/plans.json stage6-server/
First we will read the file from disk. Create a new file server.js
with the content:
var fs = require('fs'),
sys = require("sys");
fs.readFile(__dirname + "/plans.json", function (err, data) {
sys.puts(data);
});
Now run it:
node server.js
You should see the content of plans.json
file listed in terminal.
Now we will serve this file using the http
module:
var fs = require('fs'),
sys = require("sys"),
http = require('http');
http.createServer(function (request, response) {
fs.readFile(__dirname + "/plans.json", function (err, data) {
response.writeHead(200, {"Content-Type": "application/json"});
response.end(data);
sys.puts("accessed");
});
}).listen(8080);
sys.puts("Server Running on http://127.0.0.1:8080");
First we've created the server using http.createServer
method and ordered it to listen to port 8080
. On every single request plans.json
file is read and returned as a HTTP response. Then we simply log accessed
to the terminal.
Again run it using node. Navigate to http://127.0.0.1:8080 using your browser. Contents of the file will display inside the browser. (I use JSON View add-on to make the code look well.) We're able to serve the file from our machine. Now we need to read it in the app.
Let's change the request.open
to read from URL instead of the local file. In onDeviceReady
replace app_data/plans.json
with http://127.0.0.1:8080
, so it will look like:
request.open("get", "http://127.0.0.1:8080", true);
Prepare the cordova file and reload app in WebIDE.
You will receive an error Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://127.0.0.1:8080/. This can be fixed by moving the resource to the same domain or enabling CORS.. This is a CORS security preventing from loading a script from different domain.
There are two ways of loading a script from different domain. Whitelisting it in Cordova is one of it. But, as we've got the power over the server we may simply allow cross-site HTTP requests. This is done by setting a header Access-Control-Allow-Origin
. It's value is a wildcard of domain name on which is placed the script requesting the content. As our script will be placed on the phone, all domains should have access. the needed headers:
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept"
We will add these headers to the existing Content-Type
one:
response.writeHead(200, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept"});
Now cancel [Control-C]
and run the server again. Application should now be able to read from the server as from the file before.
- Serve one plan at a time
- Automatically add plans to the app
- Store loaded plans in phone memory
- Reload plan on user's action
- Settings page
- Delete plan from the phone