From 62a651369f9628418c9ba37f4c3ed9ca46cd1c28 Mon Sep 17 00:00:00 2001 From: Ben Wheeler Date: Fri, 14 Aug 2020 16:52:23 -0400 Subject: [PATCH] add support for custom options questions at app start; refactor index.js --- editions/free/src/css/start.css | 40 ++++++ editions/free/src/index.html | 5 + src/entry/app.js | 4 +- src/entry/index.js | 248 +++++++++++++++++++++++--------- src/utils/AppUsage.js | 37 ----- src/utils/InitialOptions.js | 185 ++++++++++++++++++++++++ 6 files changed, 415 insertions(+), 104 deletions(-) delete mode 100644 src/utils/AppUsage.js create mode 100644 src/utils/InitialOptions.js diff --git a/editions/free/src/css/start.css b/editions/free/src/css/start.css index f8989f0..22d3030 100644 --- a/editions/free/src/css/start.css +++ b/editions/free/src/css/start.css @@ -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; +} diff --git a/editions/free/src/index.html b/editions/free/src/index.html index d2ba348..40b2dd1 100644 --- a/editions/free/src/index.html +++ b/editions/free/src/index.html @@ -55,6 +55,11 @@
+ +
+
+
+
diff --git a/src/entry/app.js b/src/entry/app.js index 136b7d6..2b4dabd 100644 --- a/src/entry/app.js +++ b/src/entry/app.js @@ -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); }); }; diff --git a/src/entry/index.js b/src/entry/index.js index baa849c..145c076 100644 --- a/src/entry/index.js +++ b/src/entry/index.js @@ -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'); diff --git a/src/utils/AppUsage.js b/src/utils/AppUsage.js deleted file mode 100644 index 441e284..0000000 --- a/src/utils/AppUsage.js +++ /dev/null @@ -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); - } -} diff --git a/src/utils/InitialOptions.js b/src/utils/InitialOptions.js new file mode 100644 index 0000000..c24e875 --- /dev/null +++ b/src/utils/InitialOptions.js @@ -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; + } +}