add support for custom options questions at app start; refactor index.js

This commit is contained in:
Ben Wheeler 2020-08-14 16:52:23 -04:00
parent d73abf18b4
commit 62a651369f
6 changed files with 415 additions and 104 deletions

View file

@ -344,3 +344,43 @@ div.frame {
text-align: right;
}
.optionsInstruction {
position: absolute;
width: 96%;
top: 0;
padding: 2%;
color: #ffdd44;
font-size: 2.75rem;
font-weight: 500;
text-align: center;
text-shadow: 1px 1px 4px #333333;
list-style-type: none;
}
.optionsInstruction.hide {display: none;}
.optionsInstruction.show {display: block;}
.optionsList {
position: absolute;
width: 92%;
height: 86%;
padding: 4%;
top: 6%;
column-count: 3;
column-fill: auto; /* use first column up first */
color: white;
font-size: 2.25rem;
font-weight: 500;
text-align: left;
text-shadow: 1px 1px 4px #333333;
list-style-type: none;
z-index: 1000;
}
.optionsList.hide {display: none;}
.optionsList.show {display: block;}
.optionsList > div {
display: block;
padding: .25em;
}

View file

@ -55,6 +55,11 @@
<div class="usageNoanswer hide" id="usageNoanswer">
<div class="usageNoanswerText" id="usageNoanswerText"></div>
</div>
<div class="optionsInstruction hide" id="optionsInstruction">
</div>
<div class="optionsList hide" id="optionsList">
</div>
</div>
</body>
</html>

View file

@ -1,6 +1,6 @@
import {preprocessAndLoadCss} from '../utils/lib';
import Localization from '../utils/Localization';
import AppUsage from '../utils/AppUsage';
import InitialOptions from '../utils/InitialOptions';
import OS from '../tablet/OS';
import IO from '../tablet/IO';
import MediaLib from '../tablet/MediaLib';
@ -110,6 +110,6 @@ window.onload = () => {
});
});
// Initialize currentUsage data
AppUsage.initUsage();
InitialOptions.initWithSettings(window.Settings.initialOptions);
});
};

View file

@ -3,17 +3,27 @@ import {gn, getUrlVars, isAndroid, isiOS} from '../utils/lib';
import OS from '../tablet/OS';
import UI from '../editor/ui/UI';
import Localization from '../utils/Localization';
import AppUsage from '../utils/AppUsage';
import InitialOptions from '../utils/InitialOptions';
/*
When this code starts up, there are several scenarios:
* the app was already running: "AlreadyRunning"
* the app was not already running, but has been opened before: "NewSession"
* the app has never been opened before: "FirstTimeEver"
*/
let alreadyStartedQuestions = false;
export function indexMain () {
gn('gettings').ontouchend = indexGettingstarted;
gn('startcode').ontouchend = indexGohome;
ScratchAudio.init();
var urlvars = getUrlVars();
if (urlvars.back) {
indexLoadOptions();
if (urlvars.back && InitialOptions.allQuestionsAnswered()) {
indexLoadStart();
} else {
indexFirstTime();
indexNewSession();
}
if (window.Settings.edition == 'PBS') {
@ -36,7 +46,28 @@ export function indexMain () {
}, 250);
}
function indexFirstTime () {
function startQuestionsIfNotAlreadyStarted () {
if (!alreadyStartedQuestions) {
alreadyStartedQuestions = true;
indexAskRemainingQuestions();
}
window.removeEventListener('touchend', startQuestionsIfNotAlreadyStarted, false);
}
function indexNewSession () {
showSplash();
OS.askpermission(); // ask for sound recording
setTimeout(function () {
OS.hidesplash(addTouchListener);
}, 500);
// may be necessary to wait for a touch in some environments
function addTouchListener () {
window.addEventListener('touchend', startQuestionsIfNotAlreadyStarted, false);
}
setTimeout(startQuestionsIfNotAlreadyStarted, 2000);
}
function showSplash () {
gn('authors').className = 'credits show';
gn('authorsText').className = 'creditsText show';
if (window.Settings.edition == 'PBS') {
@ -49,29 +80,9 @@ function indexFirstTime () {
gn('blueguy').className = 'blue show';
gn('redguy').className = 'red show';
}
OS.askpermission(); // ask for sound recording
setTimeout(function () {
OS.hidesplash(doit);
}, 500);
function doit () {
window.ontouchend = function () {
indexLoadOptions();
};
}
setTimeout(function () {
indexLoadOptions();
}, 2000);
}
function indexLoadOptions () {
if (window.Settings.edition != 'PBS' && AppUsage.askForUsage()) {
indexLoadUsage();
} else {
indexLoadStart();
}
}
function indexLoadStart (afterUsage) {
function indexHideSplash () {
gn('authors').className = 'credits hide';
gn('authorsText').className = 'creditsText hide';
@ -84,18 +95,11 @@ function indexLoadStart (afterUsage) {
gn('blueguy').className = 'blue hide';
gn('redguy').className = 'red hide';
gn('gear').className = 'gear show';
if (afterUsage) {
gn('catface').className = 'catface show';
gn('jrlogo').className = 'jrlogo show';
gn('usageQuestion').className = 'usageQuestion hide';
gn('usageSchool').className = 'usageSchool hide';
gn('usageHome').className = 'usageHome hide';
gn('usageOther').className = 'usageOther hide';
gn('usageNoanswer').className = 'usageNoanswer hide';
}
OS.setAnalyticsPlacePref(AppUsage.currentUsage);
}
}
function indexLoadStart () {
indexHideSplash();
gn('gettings').className = 'gettings show';
gn('startcode').className = 'startcode show';
document.ontouchmove = function (e) {
@ -106,7 +110,17 @@ function indexLoadStart (afterUsage) {
}
}
function indexLoadUsage () {
function indexAskRemainingQuestions () {
indexHideSplash();
var nextQuestionKey = InitialOptions.nextUnansweredQuestion();
if (nextQuestionKey) {
indexShowQuestion(nextQuestionKey);
} else { // done with questions
indexLoadStart();
}
}
function indexAskPlace () {
gn('authors').className = 'credits show';
gn('authorsText').className = 'creditsText hide';
gn('purpleguy').className = 'purple hide';
@ -126,10 +140,137 @@ function indexLoadUsage () {
gn('usageHome').className = 'usageHome show';
gn('usageOther').className = 'usageOther show';
gn('usageNoanswer').className = 'usageNoanswer show';
gn('usageSchool').ontouchend = indexSetUsage;
gn('usageHome').ontouchend = indexSetUsage;
gn('usageOther').ontouchend = indexSetUsage;
gn('usageNoanswer').ontouchend = indexSetUsage;
gn('usageSchool').ontouchend = indexSetPlace;
gn('usageHome').ontouchend = indexSetPlace;
gn('usageOther').ontouchend = indexSetPlace;
gn('usageNoanswer').ontouchend = indexSetPlace;
}
function indexSetPlace (e) {
var usageText = '';
switch (e.target.parentElement.id) {
case 'usageSchool':
usageText = 'school';
break;
case 'usageHome':
usageText = 'home';
break;
case 'usageOther':
usageText = 'other';
break;
case 'usageNoanswer':
default:
usageText = 'noanswer';
break;
}
// Send one-time analytics event about usage
OS.analyticsEvent('lobby', 'scratchjr_usage', usageText);
InitialOptions.setValue('place', usageText);
// we use 'place_preference' for this particular Firebase pref
OS.setAnalyticsPref('place_preference', usageText);
ScratchAudio.sndFX('tap.wav');
indexHidePlaceQuestion();
indexAskRemainingQuestions();
}
function indexHidePlaceQuestion () {
gn('catface').className = 'catface show';
gn('jrlogo').className = 'jrlogo show';
gn('usageQuestion').className = 'usageQuestion hide';
gn('usageSchool').className = 'usageSchool hide';
gn('usageHome').className = 'usageHome hide';
gn('usageOther').className = 'usageOther hide';
gn('usageNoanswer').className = 'usageNoanswer hide';
}
function optionTouched (elem) {
var key = elem.target.getAttribute('data-key');
var value = elem.target.getAttribute('data-value');
// sometimes a touch is registered by a child of the relevant parent
if (!key && !value) {
var parent = elem.target.parentNode;
key = parent.getAttribute('data-key');
value = parent.getAttribute('data-value');
}
// if we still don't have a key and value, something is wrong -- just go
// to lobby
if (!key && !value) {
indexLoadStart();
return;
}
// elem.target.style.backgroundColor = 'purple';
// if everything is good, register the selection and advance to next screen
indexSelectOption(key, value);
}
// show the question for a given settings option key
function indexShowQuestion (key) {
var optionType = InitialOptions.optionTypeForKey(key);
if (optionType === 'place') {
indexAskPlace();
} else { // custom question
var options = InitialOptions.optionsForKey(key);
// if we could not find any options, choose 'n/a'
if (!options || !options.length) {
indexSelectOption(key, 'n/a');
return;
}
// if there's only one option, don't bother asking, just choose it!
if (options.length === 1) {
indexSelectOption(key, options[0]);
return;
}
// if we got here, there is more than one option...
var instructionText = InitialOptions.instructionForKey(key);
var instructionElem = document.getElementById('optionsInstruction');
instructionElem.appendChild(document.createTextNode(instructionText));
gn('optionsInstruction').className = 'optionsInstruction show';
var optionsListElem = document.getElementById('optionsList');
var optionNum = 0;
options.forEach(function (option) {
var optionElem = document.createElement('div');
optionElem.setAttribute('data-key', key);
optionElem.setAttribute('data-value', option);
optionElem.setAttribute('id', 'option-' + key + '-' + optionNum);
optionElem.ontouchend = optionTouched;
optionsListElem.appendChild(optionElem);
switch (optionType) {
case 'image':
var imgElem = document.createElement('img');
imgElem.setAttribute('src', 'svglibrary/' + option);
optionElem.appendChild(imgElem);
break;
case 'text':
default:
optionElem.appendChild(document.createTextNode(option));
break;
}
optionNum = optionNum + 1;
});
gn('optionsList').className = 'optionsList show';
}
}
// store user selection, and show next question
function indexSelectOption (key, val) {
InitialOptions.setValue(key, val);
OS.setAnalyticsPref(key, val);
ScratchAudio.sndFX('tap.wav');
// clear out old options instruction
var instructionElem = document.getElementById('optionsInstruction');
instructionElem.innerHTML = '';
gn('optionsInstruction').className = 'optionsInstruction hide';
// clear out old options content
var optionsListElem = document.getElementById('optionsList');
optionsListElem.innerHTML = '';
gn('optionsList').className = 'optionsList hide';
// show next question, or advance to start screen
indexAskRemainingQuestions();
}
function indexGohome () {
@ -153,29 +294,6 @@ function indexGettingstarted () {
window.location.href = 'gettingstarted.html?place=home';
}
function indexSetUsage (e) {
var usageText = '';
switch (e.target.parentElement.id) {
case 'usageSchool':
usageText = 'school';
break;
case 'usageHome':
usageText = 'home';
break;
case 'usageOther':
usageText = 'other';
break;
case 'usageNoanswer':
usageText = 'noanswer';
break;
}
// Send one-time analytics event about usage
OS.analyticsEvent('lobby', 'scratchjr_usage', usageText);
AppUsage.setUsage(usageText);
ScratchAudio.sndFX('tap.wav');
indexLoadStart(true);
}
// For PBS KIDS edition only
function indexInfo () {
ScratchAudio.sndFX('tap.wav');

View file

@ -1,37 +0,0 @@
import Cookie from './Cookie';
let currentUsage;
export default class AppUsage {
static get currentUsage () {
return currentUsage;
}
/**
* Initialize currentUsage for attaching to Analytics events from
* the usage cookie if it is set. currentUsage is blank if the cookie is
* not set.
*/
static initUsage () {
const usageCookie = Cookie.get('usage');
currentUsage = (usageCookie) ? usageCookie : '';
}
/**
* Check whether the App should ask for the usage data (first time launched)
* @return {boolean} True if the usage cookie has never been set
*/
static askForUsage () {
var usageCookie = Cookie.get('usage');
return usageCookie === null;
}
/**
* Set the usage cookie for tracking Analytics Events
* @param {string} kind answer from user to the usage survey (home, school, other, noanswer)
*/
static setUsage (kind) {
currentUsage = (kind === '') ? 'noanswer' : kind;
Cookie.set('usage', currentUsage);
}
}

185
src/utils/InitialOptions.js Normal file
View file

@ -0,0 +1,185 @@
import Cookie from './Cookie';
let settingsSection = null; // reference to settings.json's "initialOptions" section
let currentVals = {}; // locally cached copy of the same key-value settings in cookie
// tracks keys for options that have been set during the current session
// (useful for questions we want to ask every time the app starts up)
let answeredThisSession = {};
export default class InitialOptions {
// ****************************************
// functions only used from within InitialOptions
// ****************************************
/**
* Checks if a cookie is set for a given question
* @param {string} key indicates which options question to use
*/
static hasCookieSet (key) {
var usageCookie = Cookie.get(key);
return usageCookie !== null;
}
/**
* Checks if a given question has been answered. If the question needs to be
* asked every time the app starts, check that it has been asked this session.
* @param {object} question options question object from settings
*/
static isAnswered (question) {
if (!question) return false;
if (InitialOptions.hasCookieSet(question.key)) {
// if the cookie is set, check if the question needs to be asked
// every time (and hasn't been asked yet)
if (question.everyTime && !answeredThisSession[question.key]) {
return false;
}
return true;
}
return false;
}
/**
* Initialize currentVals for attaching to Analytics events from
* the usage cookie if it is set. currentVals is blank if the cookie is
* not set.
* @param {string} key indicates which options question to use
*/
static initKeyFromCookie (key) {
const usageCookie = Cookie.get(key);
currentVals[key] = (usageCookie) ? usageCookie : '';
}
/**
* Get value for a given question and a given field for that question
* @param {string} questionKey indicates which options question to use
* @param {string} fieldKey indicates which field of that question to use
*/
static valForKeyAndField (questionKey, fieldKey) {
if (!settingsSection || !settingsSection.length) return null;
var question = settingsSection.find(function (question) {
return (question.key === questionKey);
});
if (!question || !question[fieldKey]) return null;
return question[fieldKey];
}
// ****************************************
// functions called from outside InitialOptions
// ****************************************
/**
* Process settings object from settings.json, and initialize values
* using cookie values
* @param {object} settingsSectionParam JSON-derived object with entire
* initialOptions section from settings
*/
static initWithSettings (settingsSectionParam) {
settingsSection = settingsSectionParam;
if (!settingsSection) return;
settingsSection.forEach(function (question) {
// question is like {key: OPTION_NAME, options: [...]}
if (question.firstTime && !question.everyTime) {
InitialOptions.initKeyFromCookie(question.key);
}
});
settingsSection.forEach(function (question) {
// question is like {key: OPTION_NAME, options: [...]}
if (question.everyTime) {
InitialOptions.initKeyFromCookie(question.key);
}
});
}
/**
* Get instruction for given question
* @param {string} key indicates which options question to use
*/
static instructionForKey (key) {
return InitialOptions.valForKeyAndField(key, 'instruction');
}
/**
* Get question type for given question
* @param {string} key indicates which options question to use
*/
static optionTypeForKey (key) {
var type = InitialOptions.valForKeyAndField(key, 'type');
if (!type) {
type = 'text'; // default
}
return type;
}
/**
* Get array of posible value choices for given question.
* If question depends on a previous response, use that response to
* determine which value choices to show.
* @param {string} key indicates which options question to use
*/
static optionsForKey (key) {
if (!settingsSection || !settingsSection.length) return null;
var question = settingsSection.find(function (question) {
return (question.key === key);
});
if (!question || !question.values) return null;
var valuesKey = 'default';
if (question.dependsOn) {
var prevSelection = currentVals[question.dependsOn];
if (question.values[prevSelection]) valuesKey = prevSelection;
}
if (!question.values[valuesKey] || !question.values[valuesKey].length) return null;
return question.values[valuesKey];
}
/**
* Checks if all of the questions have options set. If any question doesn't
* have a cookie value set, or if it needs to be asked every time the app
* starts and hasn't yet, return false.
*/
static allQuestionsAnswered () {
if (!settingsSection || !settingsSection.length) return true;
settingsSection.forEach(function (question) {
if (!InitialOptions.isAnswered(question)) {
return false;
}
});
return true;
}
/**
* Gets array of keys of all questions that still need to be answered
* in this app session
*/
// static unansweredQuestions () {
// if (!settingsSection || !settingsSection.length) return [];
// return settingsSection.filter(function (question) {
// return !InitialOptions.isAnswered(question);
// }).map(function (question) {
// return question.key;
// });
// }
/**
* Gets next question that needs to be asked
*/
static nextUnansweredQuestion () {
if (!settingsSection || !settingsSection.length) return [];
var nextUnansweredQuestion = settingsSection.find(function (question) {
return !InitialOptions.isAnswered(question);
});
if (nextUnansweredQuestion) return nextUnansweredQuestion.key;
return null;
}
/**
* Set an options value in both cookie, and local object.
* @param {string} key indicates which options question this value is for
* @param {string} value option chosen by user
*/
static setValue (key, value) {
currentVals[key] = value;
Cookie.set(key, currentVals[key]);
answeredThisSession[key] = true;
}
}