diff --git a/apps/agpsdata/ChangeLog b/apps/agpsdata/ChangeLog index 89844a132..8ada244d7 100644 --- a/apps/agpsdata/ChangeLog +++ b/apps/agpsdata/ChangeLog @@ -2,3 +2,4 @@ 0.02: Load AGPS data on app start and automatically in background 0.03: Do not load AGPS data on boot Increase minimum interval to 6 hours +0.04: Write AGPS data chunks with delay to improve reliability diff --git a/apps/agpsdata/app.js b/apps/agpsdata/app.js index 647723bb4..4a6d2ba5c 100644 --- a/apps/agpsdata/app.js +++ b/apps/agpsdata/app.js @@ -36,7 +36,7 @@ function updateAgps() { g.clear(); if (!waiting) { waiting = true; - display("Updating A-GPS..."); + display("Updating A-GPS...", "takes ~ 10 seconds"); require("agpsdata").pull(function() { waiting = false; display("A-GPS updated.", "touch to close"); diff --git a/apps/agpsdata/lib.js b/apps/agpsdata/lib.js index 7d9758c0a..34608a5c6 100644 --- a/apps/agpsdata/lib.js +++ b/apps/agpsdata/lib.js @@ -8,41 +8,52 @@ var FILE = "agpsdata.settings.json"; var settings; readSettings(); -function setAGPS(data) { - var js = jsFromBase64(data); - try { - eval(js); - return true; - } - catch(e) { - console.log("error:", e); - } - return false; +function setAGPS(b64) { + return new Promise(function(resolve, reject) { + var initCommands = "Bangle.setGPSPower(1);\n"; // turn GPS on + const gnsstype = settings.gnsstype || 1; // default GPS + initCommands += `Serial1.println("${CASIC_CHECKSUM("$PCAS04," + gnsstype)}")\n`; // set GNSS mode + // What about: + // NAV-TIMEUTC (0x01 0x10) + // NAV-PV (0x01 0x03) + // or AGPS.zip uses AID-INI (0x0B 0x01) + + eval(initCommands); + + try { + writeChunks(atob(b64), resolve); + } catch (e) { + console.log("error:", e); + reject(); + } + }); } -function jsFromBase64(b64) { - var bin = atob(b64); - var chunkSize = 128; - var js = "Bangle.setGPSPower(1);\n"; // turn GPS on - var gnsstype = settings.gnsstype || 1; // default GPS - js += `Serial1.println("${CASIC_CHECKSUM("$PCAS04,"+gnsstype)}")\n`; // set GNSS mode - // What about: - // NAV-TIMEUTC (0x01 0x10) - // NAV-PV (0x01 0x03) - // or AGPS.zip uses AID-INI (0x0B 0x01) +var chunkI = 0; +function writeChunks(bin, resolve) { + return new Promise(function(resolve2) { + const chunkSize = 128; + setTimeout(function() { + if (chunkI < bin.length) { + var chunk = bin.substr(chunkI, chunkSize); + js = `Serial1.write(atob("${btoa(chunk)}"))\n`; + eval(js); - for (var i=0;i { - let result = setAGPS(event.resp); - if (result) { - updateLastUpdate(); - if (successCallback) successCallback(); - } else { - console.log("error applying AGPS data"); - if (failureCallback) failureCallback("Error applying AGPS data"); - } - }).catch((e)=>{ - console.log("error", e); - if (failureCallback) failureCallback(e); - }); + const uri = "https://www.espruino.com/agps/casic.base64"; + if (Bangle.http) { + Bangle.http(uri, {timeout : 10000}) + .then(event => { + setAGPS(event.resp) + .then(r => { + updateLastUpdate(); + if (successCallback) + successCallback(); + }) + .catch((e) => { + console.log("error", e); + if (failureCallback) + failureCallback(e); + }); + }) + .catch((e) => { + console.log("error", e); + if (failureCallback) + failureCallback(e); + }); } else { console.log("error: No http method found"); - if (failureCallback) failureCallback(/*LANG*/"No http method"); + if (failureCallback) + failureCallback(/*LANG*/ "No http method"); } }; diff --git a/apps/agpsdata/metadata.json b/apps/agpsdata/metadata.json index 1ce299532..203a00f72 100644 --- a/apps/agpsdata/metadata.json +++ b/apps/agpsdata/metadata.json @@ -2,7 +2,7 @@ "name": "A-GPS Data Downloader App", "shortName":"A-GPS Data", "icon": "agpsdata.png", - "version":"0.03", + "version":"0.04", "description": "Once installed, this app allows you to download assisted GPS (A-GPS) data directly to your Bangle.js **via Gadgetbridge on an Android phone** when you run the app. If you just want to upload the latest AGPS data from this app loader, please use the `Assisted GPS Update (AGPS)` app.", "tags": "boot,tool,assisted,gps,agps,http", "allow_emulator":true, diff --git a/apps/barwatch/ChangeLog b/apps/barwatch/ChangeLog new file mode 100644 index 000000000..7f837e50e --- /dev/null +++ b/apps/barwatch/ChangeLog @@ -0,0 +1 @@ +0.01: First version diff --git a/apps/barwatch/README.md b/apps/barwatch/README.md new file mode 100644 index 000000000..c37caa6e4 --- /dev/null +++ b/apps/barwatch/README.md @@ -0,0 +1,5 @@ +# BarWatch - an experimental watch + +For too long the watches have shown the time with digits or hands. No more! +With this stylish watch the time is represented by bars. Up to 24 as the day goes by. +Practical? Not really, but a different look! \ No newline at end of file diff --git a/apps/barwatch/app-icon.js b/apps/barwatch/app-icon.js new file mode 100644 index 000000000..82416ee28 --- /dev/null +++ b/apps/barwatch/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("l0uwkE/4A/AH4A/AB0gicQmUB+EPgEigExh8gj8A+ECAgMQn4WCgcACyotWC34W/C34W/CycACw0wgYWFBYIWCAAc/+YGHCAgNFACkxl8hGYwAMLYUvCykQC34WycoIW/C34W0gAWTmUjkUzkbmSAFY=")) \ No newline at end of file diff --git a/apps/barwatch/app.js b/apps/barwatch/app.js new file mode 100644 index 000000000..e0ed15ce6 --- /dev/null +++ b/apps/barwatch/app.js @@ -0,0 +1,76 @@ +// timeout used to update every minute +var drawTimeout; + +// schedule a draw for the next minute +function queueDraw() { + if (drawTimeout) clearTimeout(drawTimeout); + drawTimeout = setTimeout(function() { + drawTimeout = undefined; + draw(); + }, 60000 - (Date.now() % 60000)); +} + + +function draw() { + g.reset(); + + if(g.theme.dark){ + g.setColor(1,1,1); + }else{ + g.setColor(0,0,0); + } + + // work out how to display the current time + var d = new Date(); + var h = d.getHours(), m = d.getMinutes(); + + // hour bars + var bx_offset = 10, by_offset = 35; + var b_width = 8, b_height = 60; + var b_space = 5; + + for(var i=0; i 11){ + by_offset = 105; + } + var iter = i % 12; + //console.log(iter); + g.fillRect(bx_offset+(b_width*(iter+1))+(b_space*iter), + by_offset, + bx_offset+(b_width*iter)+(b_space*iter), + by_offset+b_height); + } + + // minute bar + if(h > 11){ + by_offset = 105; + } + var m_bar = h % 12; + if(m != 0){ + g.fillRect(bx_offset+(b_width*(m_bar+1))+(b_space*m_bar), + by_offset+b_height-m, + bx_offset+(b_width*m_bar)+(b_space*m_bar), + by_offset+b_height); + } + + // queue draw in one minute + queueDraw(); +} + +// Clear the screen once, at startup +g.clear(); +// draw immediately at first +draw(); +// Stop updates when LCD is off, restart when on +Bangle.on('lcdPower',on=>{ + if (on) { + draw(); // draw immediately, queue redraw + } else { // stop draw timer + if (drawTimeout) clearTimeout(drawTimeout); + drawTimeout = undefined; + } +}); + +Bangle.setUI("clock"); +Bangle.loadWidgets(); +Bangle.drawWidgets(); diff --git a/apps/barwatch/app.png b/apps/barwatch/app.png new file mode 100644 index 000000000..134de9424 Binary files /dev/null and b/apps/barwatch/app.png differ diff --git a/apps/barwatch/metadata.json b/apps/barwatch/metadata.json new file mode 100644 index 000000000..adcd44107 --- /dev/null +++ b/apps/barwatch/metadata.json @@ -0,0 +1,18 @@ +{ + "id": "barwatch", + "name": "BarWatch", + "shortName":"BarWatch", + "version":"0.01", + "description": "A watch that displays the time using bars. One bar for each hour.", + "readme": "README.md", + "icon": "screenshot.png", + "tags": "clock", + "type": "clock", + "allow_emulator":true, + "screenshots" : [ { "url": "screenshot.png" } ], + "supports" : ["BANGLEJS2"], + "storage": [ + {"name":"barwatch.app.js","url":"app.js"}, + {"name":"barwatch.img","url":"app-icon.js","evaluate":true} + ] +} diff --git a/apps/barwatch/screenshot.png b/apps/barwatch/screenshot.png new file mode 100644 index 000000000..305138252 Binary files /dev/null and b/apps/barwatch/screenshot.png differ diff --git a/apps/bthrm/ChangeLog b/apps/bthrm/ChangeLog index a70ae3f8a..c7b5a865f 100644 --- a/apps/bthrm/ChangeLog +++ b/apps/bthrm/ChangeLog @@ -30,3 +30,7 @@ Allow recording unmodified internal HR Better connection retry handling 0.13: Less time used during boot if disabled +0.14: Allow bonding (Debug menu) + Prevent mixing of BT and internal HRM events if both are enabled + Always use a grace period (default 0 ms) to decouple some connection steps + Device not found errors now utilize increasing timeouts diff --git a/apps/bthrm/default.json b/apps/bthrm/default.json index fb284bcd2..2c729ec68 100644 --- a/apps/bthrm/default.json +++ b/apps/bthrm/default.json @@ -16,5 +16,6 @@ "gracePeriodNotification": 0, "gracePeriodConnect": 0, "gracePeriodService": 0, - "gracePeriodRequest": 0 + "gracePeriodRequest": 0, + "bonding": false } diff --git a/apps/bthrm/lib.js b/apps/bthrm/lib.js index 9e2f0fe63..13e8b0383 100644 --- a/apps/bthrm/lib.js +++ b/apps/bthrm/lib.js @@ -109,6 +109,7 @@ exports.enable = () => { if (supportedCharacteristics["0x2a37"].active) stopFallback(); if (bpmTimeout) clearTimeout(bpmTimeout); bpmTimeout = setTimeout(()=>{ + bpmTimeout = undefined; supportedCharacteristics["0x2a37"].active = false; startFallback(); }, 3000); @@ -154,8 +155,8 @@ exports.enable = () => { src: "bthrm" }; - log("Emitting HRM", repEvent); - Bangle.emit("HRM_int", repEvent); + log("Emitting aggregated HRM", repEvent); + Bangle.emit("HRM_R", repEvent); } var newEvent = { @@ -280,7 +281,11 @@ exports.enable = () => { log("Disconnect: " + reason); log("GATT", gatt); log("Characteristics", characteristics); - clearRetryTimeout(reason != "Connection Timeout"); + + var retryTimeResetNeeded = true; + retryTimeResetNeeded &= reason != "Connection Timeout"; + retryTimeResetNeeded &= reason != "No device found matching filters"; + clearRetryTimeout(retryTimeResetNeeded); supportedCharacteristics["0x2a37"].active = false; startFallback(); blockInit = false; @@ -312,13 +317,13 @@ exports.enable = () => { result = result.then(()=>{ log("Starting notifications", newCharacteristic); var startPromise = newCharacteristic.startNotifications().then(()=>log("Notifications started", newCharacteristic)); - if (settings.gracePeriodNotification > 0){ - log("Add " + settings.gracePeriodNotification + "ms grace period after starting notifications"); - startPromise = startPromise.then(()=>{ - log("Wait after connect"); - return waitingPromise(settings.gracePeriodNotification); - }); - } + + log("Add " + settings.gracePeriodNotification + "ms grace period after starting notifications"); + startPromise = startPromise.then(()=>{ + log("Wait after connect"); + return waitingPromise(settings.gracePeriodNotification); + }); + return startPromise; }); } @@ -429,30 +434,30 @@ exports.enable = () => { var connectPromise = gatt.connect(connectSettings).then(function() { log("Connected."); }); - if (settings.gracePeriodConnect > 0){ - log("Add " + settings.gracePeriodConnect + "ms grace period after connecting"); - connectPromise = connectPromise.then(()=>{ - log("Wait after connect"); - return waitingPromise(settings.gracePeriodConnect); - }); - } + log("Add " + settings.gracePeriodConnect + "ms grace period after connecting"); + connectPromise = connectPromise.then(()=>{ + log("Wait after connect"); + return waitingPromise(settings.gracePeriodConnect); + }); return connectPromise; } else { return Promise.resolve(); } }); - -/* promise = promise.then(() => { - log(JSON.stringify(gatt.getSecurityStatus())); - if (gatt.getSecurityStatus()['bonded']) { - log("Already bonded"); - return Promise.resolve(); - } else { - log("Start bonding"); - return gatt.startBonding() - .then(() => console.log(gatt.getSecurityStatus())); - } - });*/ + + if (settings.bonding){ + promise = promise.then(() => { + log(JSON.stringify(gatt.getSecurityStatus())); + if (gatt.getSecurityStatus()['bonded']) { + log("Already bonded"); + return Promise.resolve(); + } else { + log("Start bonding"); + return gatt.startBonding() + .then(() => console.log(gatt.getSecurityStatus())); + } + }); + } promise = promise.then(()=>{ if (!characteristics || characteristics.length === 0){ @@ -476,13 +481,11 @@ exports.enable = () => { log("Supporting service", service.uuid); result = attachServicePromise(result, service); } - if (settings.gracePeriodService > 0) { - log("Add " + settings.gracePeriodService + "ms grace period after services"); - result = result.then(()=>{ - log("Wait after services"); - return waitingPromise(settings.gracePeriodService); - }); - } + log("Add " + settings.gracePeriodService + "ms grace period after services"); + result = result.then(()=>{ + log("Wait after services"); + return waitingPromise(settings.gracePeriodService); + }); return result; }); } else { @@ -538,35 +541,33 @@ exports.enable = () => { }; if (settings.replace){ + // register a listener for original HRM events and emit as HRM_int Bangle.on("HRM", (e) => { e.modified = true; Bangle.emit("HRM_int", e); + if (fallbackActive){ + // if fallback to internal HRM is active, emit as HRM_R to which everyone listens + Bangle.emit("HRM_R", e); + } }); - Bangle.origOn = Bangle.on; - Bangle.on = function(name, callback) { - if (name == "HRM") { - Bangle.origOn("HRM_int", callback); - } else { - Bangle.origOn(name, callback); - } - }; - - Bangle.origRemoveListener = Bangle.removeListener; - Bangle.removeListener = function(name, callback) { - if (name == "HRM") { - Bangle.origRemoveListener("HRM_int", callback); - } else { - Bangle.origRemoveListener(name, callback); - } - }; + // force all apps wanting to listen to HRM to actually get events for HRM_R + Bangle.on = ( o => (name, cb) => { + o = o.bind(Bangle); + if (name == "HRM") o("HRM_R", cb); + else o(name, cb); + })(Bangle.on); + Bangle.removeListener = ( o => (name, cb) => { + o = o.bind(Bangle); + if (name == "HRM") o("HRM_R", cb); + else o(name, cb); + })(Bangle.removeListener); } Bangle.origSetHRMPower = Bangle.setHRMPower; if (settings.startWithHrm){ - Bangle.setHRMPower = function(isOn, app) { log("setHRMPower for " + app + ": " + (isOn?"on":"off")); if (settings.enabled){ diff --git a/apps/bthrm/metadata.json b/apps/bthrm/metadata.json index 7eedd223c..df0ac1fc1 100644 --- a/apps/bthrm/metadata.json +++ b/apps/bthrm/metadata.json @@ -2,7 +2,7 @@ "id": "bthrm", "name": "Bluetooth Heart Rate Monitor", "shortName": "BT HRM", - "version": "0.13", + "version": "0.14", "description": "Overrides Bangle.js's build in heart rate monitor with an external Bluetooth one.", "icon": "app.png", "type": "app", diff --git a/apps/bthrm/settings.js b/apps/bthrm/settings.js index 2b19ea46a..fb5aa04da 100644 --- a/apps/bthrm/settings.js +++ b/apps/bthrm/settings.js @@ -96,6 +96,12 @@ writeSettings("debuglog",v); } }, + 'Use bonding': { + value: !!settings.bonding, + onchange: v => { + writeSettings("bonding",v); + } + }, 'Grace periods': function() { E.showMenu(submenu_grace); } }; diff --git a/apps/calculator/ChangeLog b/apps/calculator/ChangeLog index a08a0f5a7..2e1ace7bf 100644 --- a/apps/calculator/ChangeLog +++ b/apps/calculator/ChangeLog @@ -3,3 +3,5 @@ 0.03: Support for different screen sizes and touchscreen 0.04: Display current operation on LHS 0.05: Grid positioning and swipe controls to switch between numbers, operators and special (for Bangle.js 2) +0.06: Bangle.js 2: Exit with a short press of the physical button +0.07: Bangle.js 2: Exit by pressing upper left corner of the screen diff --git a/apps/calculator/README.md b/apps/calculator/README.md index b25d355bf..62f6cef24 100644 --- a/apps/calculator/README.md +++ b/apps/calculator/README.md @@ -12,12 +12,20 @@ Basic calculator reminiscent of MacOs's one. Handy for small calculus. ## Controls +Bangle.js 1 - UP: BTN1 - DOWN: BTN3 - LEFT: BTN4 - RIGHT: BTN5 - SELECT: BTN2 +Bangle.js 2 +- Swipes to change visible buttons +- Click physical button to exit +- Press upper left corner of screen to exit (where the red back button would be) ## Creator + +## Contributors +[thyttan](https://github.com/thyttan) diff --git a/apps/calculator/app.js b/apps/calculator/app.js index 40953254e..d9a89a989 100644 --- a/apps/calculator/app.js +++ b/apps/calculator/app.js @@ -3,6 +3,8 @@ * * Original Author: Frederic Rousseau https://github.com/fredericrous * Created: April 2020 + * + * Contributors: thyttan https://github.com/thyttan */ g.clear(); @@ -402,43 +404,42 @@ if (process.env.HWVERSION==1) { swipeEnabled = false; drawGlobal(); } else { // touchscreen? - selected = "NONE"; + selected = "NONE"; swipeEnabled = true; prepareScreen(numbers, numbersGrid, COLORS.DEFAULT); prepareScreen(operators, operatorsGrid, COLORS.OPERATOR); prepareScreen(specials, specialsGrid, COLORS.SPECIAL); drawNumbers(); - Bangle.on('touch',(n,e)=>{ - for (var key in screen) { - if (typeof screen[key] == "undefined") break; - var r = screen[key].xy; - if (e.x>=r[0] && e.y>=r[1] && - e.x{ + for (var key in screen) { + if (typeof screen[key] == "undefined") break; + var r = screen[key].xy; + if (e.x>=r[0] && e.y>=r[1] && e.x { - if (!e.b) { - if (lastX > 50) { // right + }, + swipe : (LR, UD) => { + if (LR == 1) { // right drawSpecials(); - } else if (lastX < -50) { // left + } + if (LR == -1) { // left drawOperators(); - } else if (lastY > 50) { // down - drawNumbers(); - } else if (lastY < -50) { // up + } + if (UD == 1) { // down + drawNumbers(); + } + if (UD == -1) { // up drawNumbers(); } - lastX = 0; - lastY = 0; - } else { - lastX = lastX + e.dx; - lastY = lastY + e.dy; } }); + } - displayOutput(0); diff --git a/apps/calculator/metadata.json b/apps/calculator/metadata.json index e78e4d54f..1674b7843 100644 --- a/apps/calculator/metadata.json +++ b/apps/calculator/metadata.json @@ -2,7 +2,7 @@ "id": "calculator", "name": "Calculator", "shortName": "Calculator", - "version": "0.05", + "version": "0.07", "description": "Basic calculator reminiscent of MacOs's one. Handy for small calculus.", "icon": "calculator.png", "screenshots": [{"url":"screenshot_calculator.png"}], diff --git a/apps/dragboard/ChangeLog b/apps/dragboard/ChangeLog index 48a1ffb03..265094e87 100644 --- a/apps/dragboard/ChangeLog +++ b/apps/dragboard/ChangeLog @@ -3,3 +3,4 @@ 0.03: Made the code shorter and somewhat more readable by writing some functions. Also made it work as a library where it returns the text once finished. The keyboard is now made to exit correctly when the 'back' event is called. The keyboard now uses theme colors correctly, although it still looks best with dark theme. The numbers row is now solidly green - except for highlights. 0.04: Now displays the opened text string at launch. 0.05: Now scrolls text when string gets longer than screen width. +0.06: The code is now more reliable and the input snappier. Widgets will be drawn if present. diff --git a/apps/dragboard/lib.js b/apps/dragboard/lib.js index b9b19f982..220f075d7 100644 --- a/apps/dragboard/lib.js +++ b/apps/dragboard/lib.js @@ -1,12 +1,9 @@ -//Keep banglejs screen on for 100 sec at 0.55 power level for development purposes -//Bangle.setLCDTimeout(30); -//Bangle.setLCDPower(1); - exports.input = function(options) { options = options||{}; var text = options.text; if ("string"!=typeof text) text=""; - + + var R = Bangle.appRect; var BGCOLOR = g.theme.bg; var HLCOLOR = g.theme.fg; var ABCCOLOR = g.toColor(1,0,0);//'#FF0000'; @@ -17,35 +14,38 @@ exports.input = function(options) { var SMALLFONTWIDTH = parseInt(SMALLFONT.charAt(0)*parseInt(SMALLFONT.charAt(-1))); var ABC = 'abcdefghijklmnopqrstuvwxyz'.toUpperCase(); - var ABCPADDING = (g.getWidth()-6*ABC.length)/2; + var ABCPADDING = ((R.x+R.w)-6*ABC.length)/2; var NUM = ' 1234567890!?,.- '; var NUMHIDDEN = ' 1234567890!?,.- '; - var NUMPADDING = (g.getWidth()-6*NUM.length)/2; + var NUMPADDING = ((R.x+R.w)-6*NUM.length)/2; var rectHeight = 40; - var delSpaceLast; function drawAbcRow() { g.clear(); + try { // Draw widgets if they are present in the current app. + if (WIDGETS) Bangle.drawWidgets(); + } catch (_) {} g.setFont(SMALLFONT); g.setColor(ABCCOLOR); - g.drawString(ABC, ABCPADDING, g.getHeight()/2); - g.fillRect(0, g.getHeight()-26, g.getWidth(), g.getHeight()); + g.setFontAlign(-1, -1, 0); + g.drawString(ABC, ABCPADDING, (R.y+R.h)/2); + g.fillRect(0, (R.y+R.h)-26, (R.x+R.w), (R.y+R.h)); } function drawNumRow() { g.setFont(SMALLFONT); g.setColor(NUMCOLOR); - g.drawString(NUM, NUMPADDING, g.getHeight()/4); + g.setFontAlign(-1, -1, 0); + g.drawString(NUM, NUMPADDING, (R.y+R.h)/4); - g.fillRect(NUMPADDING, g.getHeight()-rectHeight*4/3, g.getWidth()-NUMPADDING, g.getHeight()-rectHeight*2/3); + g.fillRect(NUMPADDING, (R.y+R.h)-rectHeight*4/3, (R.x+R.w)-NUMPADDING, (R.y+R.h)-rectHeight*2/3); } function updateTopString() { - "ram" g.setColor(BGCOLOR); g.fillRect(0,4+20,176,13+20); g.setColor(0.2,0,0); @@ -54,13 +54,10 @@ exports.input = function(options) { g.setColor(0.7,0,0); g.fillRect(rectLen+5,4+20,rectLen+10,13+20); g.setColor(1,1,1); + g.setFontAlign(-1, -1, 0); g.drawString(text.length<=27? text.substr(-27, 27) : '<- '+text.substr(-24,24), 5, 5+20); } - drawAbcRow(); - drawNumRow(); - updateTopString(); - var abcHL; var abcHLPrev = -10; var numHL; @@ -68,194 +65,182 @@ exports.input = function(options) { var type = ''; var typePrev = ''; var largeCharOffset = 6; - + function resetChars(char, HLPrev, typePadding, heightDivisor, rowColor) { - "ram" + "ram"; // Small character in list g.setColor(rowColor); g.setFont(SMALLFONT); - g.drawString(char, typePadding + HLPrev*6, g.getHeight()/heightDivisor); + g.setFontAlign(-1, -1, 0); + g.drawString(char, typePadding + HLPrev*6, (R.y+R.h)/heightDivisor); // Large character g.setColor(BGCOLOR); - g.fillRect(0,g.getHeight()/3,176,g.getHeight()/3+24); - //g.drawString(charSet.charAt(HLPrev), typePadding + HLPrev*6 -largeCharOffset, g.getHeight()/3);; //Old implementation where I find the shape and place of letter to remove instead of just a rectangle. + g.fillRect(0,(R.y+R.h)/3,176,(R.y+R.h)/3+24); + //g.drawString(charSet.charAt(HLPrev), typePadding + HLPrev*6 -largeCharOffset, (R.y+R.h)/3);; //Old implementation where I find the shape and place of letter to remove instead of just a rectangle. // mark in the list } function showChars(char, HL, typePadding, heightDivisor) { - "ram" + "ram"; // mark in the list g.setColor(HLCOLOR); g.setFont(SMALLFONT); - if (char != 'del' && char != 'space') g.drawString(char, typePadding + HL*6, g.getHeight()/heightDivisor); + g.setFontAlign(-1, -1, 0); + if (char != 'del' && char != 'space') g.drawString(char, typePadding + HL*6, (R.y+R.h)/heightDivisor); // show new large character g.setFont(BIGFONT); - g.drawString(char, typePadding + HL*6 -largeCharOffset, g.getHeight()/3); + g.drawString(char, typePadding + HL*6 -largeCharOffset, (R.y+R.h)/3); g.setFont(SMALLFONT); } - + + function initDraw() { + //var R = Bangle.appRect; // To make sure it's properly updated. Not sure if this is needed. + drawAbcRow(); + drawNumRow(); + updateTopString(); + } + initDraw(); + //setTimeout(initDraw, 0); // So Bangle.appRect reads the correct environment. It would draw off to the side sometimes otherwise. + function changeCase(abcHL) { g.setColor(BGCOLOR); - g.drawString(ABC, ABCPADDING, g.getHeight()/2); + g.setFontAlign(-1, -1, 0); + g.drawString(ABC, ABCPADDING, (R.y+R.h)/2); if (ABC.charAt(abcHL) == ABC.charAt(abcHL).toUpperCase()) ABC = ABC.toLowerCase(); else ABC = ABC.toUpperCase(); g.setColor(ABCCOLOR); - g.drawString(ABC, ABCPADDING, g.getHeight()/2); + g.drawString(ABC, ABCPADDING, (R.y+R.h)/2); } return new Promise((resolve,reject) => { - // Interpret touch input + // Interpret touch input Bangle.setUI({ - mode: 'custom', - back: ()=>{ - Bangle.setUI(); - g.clearRect(Bangle.appRect); - resolve(text); - }, - drag: function(event) { + mode: 'custom', + back: ()=>{ + Bangle.setUI(); + g.clearRect(Bangle.appRect); + resolve(text); + }, + drag: function(event) { + "ram"; + // ABCDEFGHIJKLMNOPQRSTUVWXYZ + // Choose character by draging along red rectangle at bottom of screen + if (event.y >= ( (R.y+R.h) - 12 )) { + // Translate x-position to character + if (event.x < ABCPADDING) { abcHL = 0; } + else if (event.x >= 176-ABCPADDING) { abcHL = 25; } + else { abcHL = Math.floor((event.x-ABCPADDING)/6); } - // ABCDEFGHIJKLMNOPQRSTUVWXYZ - // Choose character by draging along red rectangle at bottom of screen - if (event.y >= ( g.getHeight() - 12 )) { - // Translate x-position to character - if (event.x < ABCPADDING) { abcHL = 0; } - else if (event.x >= 176-ABCPADDING) { abcHL = 25; } - else { abcHL = Math.floor((event.x-ABCPADDING)/6); } + // Datastream for development purposes + //print(event.x, event.y, event.b, ABC.charAt(abcHL), ABC.charAt(abcHLPrev)); - // Datastream for development purposes - //print(event.x, event.y, event.b, ABC.charAt(abcHL), ABC.charAt(abcHLPrev)); + // Unmark previous character and mark the current one... + // Handling switching between letters and numbers/punctuation + if (typePrev != 'abc') resetChars(NUM.charAt(numHLPrev), numHLPrev, NUMPADDING, 4, NUMCOLOR); - // Unmark previous character and mark the current one... - // Handling switching between letters and numbers/punctuation - if (typePrev != 'abc') resetChars(NUM.charAt(numHLPrev), numHLPrev, NUMPADDING, 4, NUMCOLOR); - - if (abcHL != abcHLPrev) { - resetChars(ABC.charAt(abcHLPrev), abcHLPrev, ABCPADDING, 2, ABCCOLOR); - showChars(ABC.charAt(abcHL), abcHL, ABCPADDING, 2); + if (abcHL != abcHLPrev) { + resetChars(ABC.charAt(abcHLPrev), abcHLPrev, ABCPADDING, 2, ABCCOLOR); + showChars(ABC.charAt(abcHL), abcHL, ABCPADDING, 2); } - // Print string at top of screen - if (event.b == 0) { - text = text + ABC.charAt(abcHL); - updateTopString(); - - // Autoswitching letter case - if (ABC.charAt(abcHL) == ABC.charAt(abcHL).toUpperCase()) changeCase(abcHL); - } - // Update previous character to current one - abcHLPrev = abcHL; - typePrev = 'abc'; - } - - - - - - - - - - // 12345678901234567890 - // Choose number or puctuation by draging on green rectangle - else if ((event.y < ( g.getHeight() - 12 )) && (event.y > ( g.getHeight() - 52 ))) { - // Translate x-position to character - if (event.x < NUMPADDING) { numHL = 0; } - else if (event.x > 176-NUMPADDING) { numHL = NUM.length-1; } - else { numHL = Math.floor((event.x-NUMPADDING)/6); } - - // Datastream for development purposes - //print(event.x, event.y, event.b, NUM.charAt(numHL), NUM.charAt(numHLPrev)); - - // Unmark previous character and mark the current one... - // Handling switching between letters and numbers/punctuation - if (typePrev != 'num') resetChars(ABC.charAt(abcHLPrev), abcHLPrev, ABCPADDING, 2, ABCCOLOR); - - if (numHL != numHLPrev) { - resetChars(NUM.charAt(numHLPrev), numHLPrev, NUMPADDING, 4, NUMCOLOR); - showChars(NUM.charAt(numHL), numHL, NUMPADDING, 4); - } - // Print string at top of screen - if (event.b == 0) { - g.setColor(HLCOLOR); - // Backspace if releasing before list of numbers/punctuation - if (event.x < NUMPADDING) { - // show delete sign - showChars('del', 0, g.getWidth()/2 +6 -27 , 4); - delSpaceLast = 1; - text = text.slice(0, -1); - updateTopString(); - //print(text); - } - // Append space if releasing after list of numbers/punctuation - else if (event.x > g.getWidth()-NUMPADDING) { - //show space sign - showChars('space', 0, g.getWidth()/2 +6 -6*3*5/2 , 4); - delSpaceLast = 1; - text = text + ' '; - updateTopString(); - //print(text); - } - // Append selected number/punctuation - else { - text = text + NUMHIDDEN.charAt(numHL); + // Print string at top of screen + if (event.b == 0) { + text = text + ABC.charAt(abcHL); updateTopString(); // Autoswitching letter case - if ((text.charAt(text.length-1) == '.') || (text.charAt(text.length-1) == '!')) changeCase(); + if (ABC.charAt(abcHL) == ABC.charAt(abcHL).toUpperCase()) changeCase(abcHL); } + // Update previous character to current one + abcHLPrev = abcHL; + typePrev = 'abc'; } - // Update previous character to current one - numHLPrev = numHL; - typePrev = 'num'; - } - - - - - - - - - // Make a space or backspace by swiping right or left on screen above green rectangle - else if (event.y > 20+4) { - if (event.b == 0) { - g.setColor(HLCOLOR); - if (event.x < g.getWidth()/2) { - resetChars(ABC.charAt(abcHLPrev), abcHLPrev, ABCPADDING, 2, ABCCOLOR); + + // 12345678901234567890 + // Choose number or puctuation by draging on green rectangle + else if ((event.y < ( (R.y+R.h) - 12 )) && (event.y > ( (R.y+R.h) - 52 ))) { + // Translate x-position to character + if (event.x < NUMPADDING) { numHL = 0; } + else if (event.x > 176-NUMPADDING) { numHL = NUM.length-1; } + else { numHL = Math.floor((event.x-NUMPADDING)/6); } + + // Datastream for development purposes + //print(event.x, event.y, event.b, NUM.charAt(numHL), NUM.charAt(numHLPrev)); + + // Unmark previous character and mark the current one... + // Handling switching between letters and numbers/punctuation + if (typePrev != 'num') resetChars(ABC.charAt(abcHLPrev), abcHLPrev, ABCPADDING, 2, ABCCOLOR); + + if (numHL != numHLPrev) { resetChars(NUM.charAt(numHLPrev), numHLPrev, NUMPADDING, 4, NUMCOLOR); - - // show delete sign - showChars('del', 0, g.getWidth()/2 +6 -27 , 4); - delSpaceLast = 1; - - // Backspace and draw string upper right corner - text = text.slice(0, -1); - updateTopString(); - if (text.length==0) changeCase(abcHL); - //print(text, 'undid'); + showChars(NUM.charAt(numHL), numHL, NUMPADDING, 4); } - else { - resetChars(ABC.charAt(abcHLPrev), abcHLPrev, ABCPADDING, 2, ABCCOLOR); - resetChars(NUM.charAt(numHLPrev), numHLPrev, NUMPADDING, 4, NUMCOLOR); + // Print string at top of screen + if (event.b == 0) { + g.setColor(HLCOLOR); + // Backspace if releasing before list of numbers/punctuation + if (event.x < NUMPADDING) { + // show delete sign + showChars('del', 0, (R.x+R.w)/2 +6 -27 , 4); + delSpaceLast = 1; + text = text.slice(0, -1); + updateTopString(); + //print(text); + } + // Append space if releasing after list of numbers/punctuation + else if (event.x > (R.x+R.w)-NUMPADDING) { + //show space sign + showChars('space', 0, (R.x+R.w)/2 +6 -6*3*5/2 , 4); + delSpaceLast = 1; + text = text + ' '; + updateTopString(); + //print(text); + } + // Append selected number/punctuation + else { + text = text + NUMHIDDEN.charAt(numHL); + updateTopString(); - //show space sign - showChars('space', 0, g.getWidth()/2 +6 -6*3*5/2 , 4); - delSpaceLast = 1; + // Autoswitching letter case + if ((text.charAt(text.length-1) == '.') || (text.charAt(text.length-1) == '!')) changeCase(); + } + } + // Update previous character to current one + numHLPrev = numHL; + typePrev = 'num'; + } - // Append space and draw string upper right corner - text = text + NUMHIDDEN.charAt(0); - updateTopString(); - //print(text, 'made space'); + // Make a space or backspace by swiping right or left on screen above green rectangle + else if (event.y > 20+4) { + if (event.b == 0) { + g.setColor(HLCOLOR); + if (event.x < (R.x+R.w)/2) { + resetChars(ABC.charAt(abcHLPrev), abcHLPrev, ABCPADDING, 2, ABCCOLOR); + resetChars(NUM.charAt(numHLPrev), numHLPrev, NUMPADDING, 4, NUMCOLOR); + + // show delete sign + showChars('del', 0, (R.x+R.w)/2 +6 -27 , 4); + delSpaceLast = 1; + + // Backspace and draw string upper right corner + text = text.slice(0, -1); + updateTopString(); + if (text.length==0) changeCase(abcHL); + //print(text, 'undid'); + } + else { + resetChars(ABC.charAt(abcHLPrev), abcHLPrev, ABCPADDING, 2, ABCCOLOR); + resetChars(NUM.charAt(numHLPrev), numHLPrev, NUMPADDING, 4, NUMCOLOR); + + //show space sign + showChars('space', 0, (R.x+R.w)/2 +6 -6*3*5/2 , 4); + delSpaceLast = 1; + + // Append space and draw string upper right corner + text = text + NUMHIDDEN.charAt(0); + updateTopString(); + //print(text, 'made space'); + } } } } - } - }); -}); -/* return new Promise((resolve,reject) => { - Bangle.setUI({mode:"custom", back:()=>{ - Bangle.setUI(); - g.clearRect(Bangle.appRect); - Bangle.setUI(); - resolve(text); - }}); - }); */ - + }); + }); }; diff --git a/apps/dragboard/metadata.json b/apps/dragboard/metadata.json index f9c73ddde..64b6dbe18 100644 --- a/apps/dragboard/metadata.json +++ b/apps/dragboard/metadata.json @@ -1,6 +1,6 @@ { "id": "dragboard", "name": "Dragboard", - "version":"0.05", + "version":"0.06", "description": "A library for text input via swiping keyboard", "icon": "app.png", "type":"textinput", diff --git a/apps/dtlaunch/ChangeLog b/apps/dtlaunch/ChangeLog index 16c550334..ea9ca729f 100644 --- a/apps/dtlaunch/ChangeLog +++ b/apps/dtlaunch/ChangeLog @@ -14,3 +14,8 @@ 0.14: Don't move pages when doing exit swipe - Bangle 2. 0.15: 'Swipe to exit'-code is slightly altered to be more reliable - Bangle 2. 0.16: Use default Bangle formatter for booleans +0.17: Bangle 2: Fast loading on exit to clock face. Added option for exit to +clock face by timeout. +0.18: Move interactions inside setUI. Replace "one click exit" with +back-functionality through setUI, adding the red back button as well. Hardware +button to exit is no longer an option. diff --git a/apps/dtlaunch/app-b2.js b/apps/dtlaunch/app-b2.js index 8cd5790bb..df5bfc48f 100644 --- a/apps/dtlaunch/app-b2.js +++ b/apps/dtlaunch/app-b2.js @@ -1,28 +1,27 @@ +{ // must be inside our own scope here so that when we are unloaded everything disappears + /* Desktop launcher * */ -var settings = Object.assign({ +let settings = Object.assign({ showClocks: true, showLaunchers: true, direct: false, - oneClickExit:false, - swipeExit: false + swipeExit: false, + timeOut: "Off" }, require('Storage').readJSON("dtlaunch.json", true) || {}); -if( settings.oneClickExit) - setWatch(_=> load(), BTN1); - -var s = require("Storage"); -var apps = s.list(/\.info$/).map(app=>{ - var a=s.readJSON(app,1); +let s = require("Storage"); + var apps = s.list(/\.info$/).map(app=>{ + let a=s.readJSON(app,1); return a && { name:a.name, type:a.type, icon:a.icon, sortorder:a.sortorder, src:a.src };}).filter( app=>app && (app.type=="app" || (app.type=="clock" && settings.showClocks) || (app.type=="launch" && settings.showLaunchers) || !app.type)); apps.sort((a,b)=>{ - var n=(0|a.sortorder)-(0|b.sortorder); + let n=(0|a.sortorder)-(0|b.sortorder); if (n) return n; // do sortorder first if (a.nameb.name) return 1; @@ -33,29 +32,28 @@ apps.forEach(app=>{ app.icon = s.read(app.icon); // should just be a link to a memory area }); -var Napps = apps.length; -var Npages = Math.ceil(Napps/4); -var maxPage = Npages-1; -var selected = -1; -var oldselected = -1; -var page = 0; +let Napps = apps.length; +let Npages = Math.ceil(Napps/4); +let maxPage = Npages-1; +let selected = -1; +let oldselected = -1; +let page = 0; const XOFF = 24; const YOFF = 30; -function draw_icon(p,n,selected) { - var x = (n%2)*72+XOFF; - var y = n>1?72+YOFF:YOFF; +let drawIcon= function(p,n,selected) { + let x = (n%2)*72+XOFF; + let y = n>1?72+YOFF:YOFF; (selected?g.setColor(g.theme.fgH):g.setColor(g.theme.bg)).fillRect(x+11,y+3,x+60,y+52); g.clearRect(x+12,y+4,x+59,y+51); g.setColor(g.theme.fg); try{g.drawImage(apps[p*4+n].icon,x+12,y+4);} catch(e){} g.setFontAlign(0,-1,0).setFont("6x8",1); - var txt = apps[p*4+n].name.replace(/([a-z])([A-Z])/g, "$1 $2").split(" "); - var lineY = 0; - var line = ""; - while (txt.length > 0){ - var c = txt.shift(); - + let txt = apps[p*4+n].name.replace(/([a-z])([A-Z])/g, "$1 $2").split(" "); + let lineY = 0; + let line = ""; + while (txt.length > 0){ + let c = txt.shift(); if (c.length + 1 + line.length > 13){ if (line.length > 0){ g.drawString(line.trim(),x+36,y+54+lineY*8); @@ -67,29 +65,34 @@ function draw_icon(p,n,selected) { } } g.drawString(line.trim(),x+36,y+54+lineY*8); -} +}; -function drawPage(p){ +let drawPage = function(p){ g.reset(); g.clearRect(0,24,175,175); - var O = 88+YOFF/2-12*(Npages/2); - for (var j=0;j{ +Bangle.loadWidgets(); +//g.clear(); +//Bangle.drawWidgets(); +drawPage(0); + +let swipeListenerDt = function(dirLeftRight, dirUpDown){ selected = 0; oldselected=-1; - if(settings.swipeExit && dirLeftRight==1) load(); + if(settings.swipeExit && dirLeftRight==1) returnToClock(); if (dirUpDown==-1||dirLeftRight==-1){ ++page; if (page>maxPage) page=0; drawPage(page); @@ -97,24 +100,24 @@ Bangle.on("swipe",(dirLeftRight, dirUpDown)=>{ --page; if (page<0) page=maxPage; drawPage(page); } -}); +}; -function isTouched(p,n){ +let isTouched = function(p,n){ if (n<0 || n>3) return false; - var x1 = (n%2)*72+XOFF; var y1 = n>1?72+YOFF:YOFF; - var x2 = x1+71; var y2 = y1+81; + let x1 = (n%2)*72+XOFF; let y1 = n>1?72+YOFF:YOFF; + let x2 = x1+71; let y2 = y1+81; return (p.x>x1 && p.y>y1 && p.x{ - var i; +let touchListenerDt = function(_,p){ + let i; for (i=0;i<4;i++){ if((page*4+i)=0 || settings.direct) { if (selected!=i && !settings.direct){ - draw_icon(page,selected,false); + drawIcon(page,selected,false); } else { load(apps[page*4+i].src); } @@ -125,12 +128,32 @@ Bangle.on("touch",(_,p)=>{ } } if ((i==4 || (page*4+i)>Napps) && selected>=0) { - draw_icon(page,selected,false); + drawIcon(page,selected,false); selected=-1; } +}; + +const returnToClock = function() { + Bangle.setUI(); + setTimeout(eval, 0, s.read(".bootcde")); +}; + +Bangle.setUI({ + mode : 'custom', + back : returnToClock, + swipe : swipeListenerDt, + touch : touchListenerDt }); -Bangle.loadWidgets(); -g.clear(); -Bangle.drawWidgets(); -drawPage(0); +// taken from Icon Launcher with minor alterations +var timeoutToClock; +const updateTimeoutToClock = function(){ + if (settings.timeOut!="Off"){ + let time=parseInt(settings.timeOut); //the "s" will be trimmed by the parseInt + if (timeoutToClock) clearTimeout(timeoutToClock); + timeoutToClock = setTimeout(returnToClock,time*1000); + } +}; +updateTimeoutToClock(); + +} // end of app scope diff --git a/apps/dtlaunch/metadata.json b/apps/dtlaunch/metadata.json index 36728f342..2892b758e 100644 --- a/apps/dtlaunch/metadata.json +++ b/apps/dtlaunch/metadata.json @@ -1,7 +1,7 @@ { "id": "dtlaunch", "name": "Desktop Launcher", - "version": "0.16", + "version": "0.18", "description": "Desktop style App Launcher with six (four for Bangle 2) apps per page - fast access if you have lots of apps installed.", "screenshots": [{"url":"shot1.png"},{"url":"shot2.png"},{"url":"shot3.png"}], "icon": "icon.png", diff --git a/apps/dtlaunch/settings-b2.js b/apps/dtlaunch/settings-b2.js index fac9c0fff..24959df8c 100644 --- a/apps/dtlaunch/settings-b2.js +++ b/apps/dtlaunch/settings-b2.js @@ -5,51 +5,56 @@ showClocks: true, showLaunchers: true, direct: false, - oneClickExit:false, - swipeExit: false + swipeExit: false, + timeOut: "Off" }, require('Storage').readJSON(FILE, true) || {}); function writeSettings() { require('Storage').writeJSON(FILE, settings); } + const timeOutChoices = [/*LANG*/"Off", "10s", "15s", "20s", "30s"]; + E.showMenu({ "" : { "title" : "Desktop launcher" }, - "< Back" : () => back(), - 'Show clocks': { + /*LANG*/"< Back" : () => back(), + /*LANG*/'Show clocks': { value: settings.showClocks, onchange: v => { settings.showClocks = v; writeSettings(); } }, - 'Show launchers': { + /*LANG*/'Show launchers': { value: settings.showLaunchers, onchange: v => { settings.showLaunchers = v; writeSettings(); } }, - 'Direct launch': { + /*LANG*/'Direct launch': { value: settings.direct, onchange: v => { settings.direct = v; writeSettings(); } }, - 'Swipe Exit': { + /*LANG*/'Swipe Exit': { value: settings.swipeExit, onchange: v => { settings.swipeExit = v; writeSettings(); } }, - 'One click exit': { - value: settings.oneClickExit, + /*LANG*/'Time Out': { // Adapted from Icon Launcher + value: timeOutChoices.indexOf(settings.timeOut), + min: 0, + max: timeOutChoices.length-1, + format: v => timeOutChoices[v], onchange: v => { - settings.oneClickExit = v; + settings.timeOut = timeOutChoices[v]; writeSettings(); } } }); -}) +}); diff --git a/apps/entonclk/ChangeLog b/apps/entonclk/ChangeLog new file mode 100644 index 000000000..62e2d0c20 --- /dev/null +++ b/apps/entonclk/ChangeLog @@ -0,0 +1 @@ +0.1: New App! \ No newline at end of file diff --git a/apps/entonclk/README.md b/apps/entonclk/README.md new file mode 100644 index 000000000..8c788c7a5 --- /dev/null +++ b/apps/entonclk/README.md @@ -0,0 +1,9 @@ +Enton - Enhanced Anton Clock + +This clock face is based on the 'Anton Clock'. + +Things I changed: + +- The main font for the time is now Audiowide +- Removed the written out day name and replaced it with steps and bpm +- Changed the date string to a (for me) more readable string \ No newline at end of file diff --git a/apps/entonclk/app-icon.js b/apps/entonclk/app-icon.js new file mode 100644 index 000000000..9993b0871 --- /dev/null +++ b/apps/entonclk/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwkE/4A/AH4A/AH4A/AH4Aw+cikf/mQDCAAIFBAwQDBBYgXCgEDAQIABn4JBkAFBgIKDgQwFmMD+UCmcgl/zEIMzmcQmYKBmYiCAAfxC4QrBl8wBwcgkYsGC4sAiMAF4UxiIGBn8QAgMSC48wgMRiEDBAISCiYcFC48v//yC4PzgJAGiAXIiczPgPzC4JyBmf/AYQXI+KcCj8wmYFCgEjAYQ3G+cjbQIABJIMzAoUin7XIADpSEK4rWGI4MhmRJBn8j+U/d4MimUTkUzIw5dBl4UBMgIXBAgMyLYKOBmQXHiSbCDgMyl8z+UjmJ1BHgJbHCgM/IYQABAgQJBYYYA/AH4AtaQU/mTvBBozWBd44KBkUSkLnBEo8jkcvBI0/CgMiDAIXHHYIXImUzJQJHH+Y+Bn6Z/ABQA==")) \ No newline at end of file diff --git a/apps/entonclk/app.js b/apps/entonclk/app.js new file mode 100644 index 000000000..69fdea479 --- /dev/null +++ b/apps/entonclk/app.js @@ -0,0 +1,67 @@ +Graphics.prototype.setFontAudiowide = function() { + // Actual height 33 (36 - 4) + var widths = atob("CiAsESQjJSQkHyQkDA=="); + var font = atob("AAAAAAAAAAAAAAAAAAAAAPAAAAAAAfgAAAAAAfgAAAAAAfgAAAAAAfgAAAAAAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAADgAAAAAAHgAAAAAAfgAAAAAA/gAAAAAD/gAAAAAH/gAAAAAf/AAAAAB/8AAAAAD/4AAAAAP/gAAAAAf/AAAAAB/8AAAAAD/4AAAAAP/gAAAAAf+AAAAAB/8AAAAAH/wAAAAAP/gAAAAA/+AAAAAB/8AAAAAD/wAAAAAD/gAAAAAD+AAAAAAD4AAAAAADwAAAAAADAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAA//+AAAAB///AAAAH///wAAAP///4AAAf///8AAA////+AAA/4AP+AAB/gAD/AAB/AA9/AAD+AB+/gAD+AD+/gAD+AD+/gAD8AH+fgAD8AP8fgAD8AP4fgAD8Af4fgAD8A/wfgAD8A/gfgAD8B/gfgAD8D/AfgAD8D+AfgAD8H+AfgAD8P8AfgAD8P4AfgAD8f4AfgAD8/wAfgAD8/gAfgAD+/gA/gAD+/AA/gAB/eAB/AAB/sAD/AAB/wAH/AAA////+AAAf///8AAAP///4AAAH///wAAAD///gAAAA//+AAAAAP/4AAAAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAD8AAAAAAD8AAAAAAD8AAAAAAD8AAAAAAD8AAAAAAD/////gAD/////gAD/////gAD/////gAD/////gAD/////gAD/////gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//gAAAAH//gAAAAP//gAD8Af//gAD8A///gAD8B///gAD8B///gAD8B/AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD+D+AfgAD//+AfgAD//+AfgAB//8AfgAA//4AfgAAf/wAfgAAP/gAfgAAB8AAfgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD+B+A/gAD/////gAB/////AAB/////AAA////+AAAf///8AAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//4AAAAD//8AAAAD//+AAAAD//+AAAAD//+AAAAD//+AAAAD//+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAAAB+AAAAD/////gAD/////gAD/////gAD/////gAD/////gAD/////gAD/////gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//AAfgAD//wAfgAD//4AfgAD//8AfgAD//8AfgAD//+AfgAD8D+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B/A/gAD8B///gAD8B///gAD8A///AAD8A///AAAAAf/+AAAAAP/4AAAAAD/gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB///AAAAH///wAAAf///8AAAf///8AAA////+AAB/////AAB/h+H/AAD/B+B/gAD+B+A/gAD+B+A/gAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B/A/gAD8B///gAD8B///gAD8A///AAAAAf//AAAAAf/+AAAAAH/4AAAAAB/gAAAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAD8AAAAAAD8AAAAAAD8AAAAAAD8AAAAgAD8AAABgAD8AAAHgAD8AAAfgAD8AAA/gAD8AAD/gAD8AAP/gAD8AA//gAD8AB//AAD8AH/8AAD8Af/wAAD8A//AAAD8D/+AAAD8P/4AAAD8f/gAAAD9//AAAAD//8AAAAD//wAAAAD//gAAAAD/+AAAAAD/4AAAAAD/wAAAAAD/AAAAAAD8AAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/gAAAAAH/4AAAAAP/8AAAH+f/+AAAf////AAA/////gAB/////gAB///A/gAD//+AfgAD//+AfgAD+D+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD+D+AfgAD//+AfgAD//+AfgAB///A/gAB/////gAA/////AAAP////AAAD+f/+AAAAAP/8AAAAAH/4AAAAAA+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH/AAAAAAf/wAAAAA//4AAAAB//8AAAAB//8AfgAD//+AfgAD/D+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD8B+AfgAD+B+A/gAD+B+A/gAD/B+B/gAB/////AAB/////AAA////+AAAf///8AAAP///4AAAH///wAAAB///AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeAAPAAAA/AAfgAAA/AAfgAAA/AAfgAAA/AAfgAAAeAAPAAAAAAAAAAAAAAAAAAAAAAAAAA"); + var scale = 1; // size multiplier for this font + g.setFontCustom(font, 46, widths, 48+(scale<<8)+(1<<16)); +}; + +function getSteps() { + var steps = 0; + try{ + if (WIDGETS.wpedom !== undefined) { + steps = WIDGETS.wpedom.getSteps(); + } else if (WIDGETS.activepedom !== undefined) { + steps = WIDGETS.activepedom.getSteps(); + } else { + steps = Bangle.getHealthStatus("day").steps; + } + } catch(ex) { + // In case we failed, we can only show 0 steps. + return "?"; + } + + return Math.round(steps); +} + +{ // must be inside our own scope here so that when we are unloaded everything disappears + // we also define functions using 'let fn = function() {..}' for the same reason. function decls are global +let drawTimeout; + + +// Actually draw the watch face +let draw = function() { + var x = g.getWidth() / 2; + var y = g.getHeight() / 2; + g.reset().clearRect(Bangle.appRect); // clear whole background (w/o widgets) + var date = new Date(); + var timeStr = require("locale").time(date, 1); // Hour and minute + g.setFontAlign(0, 0).setFont("Audiowide").drawString(timeStr, x, y); + var dateStr = require("locale").date(date, 1).toUpperCase(); + g.setFontAlign(0, 0).setFont("6x8", 2).drawString(dateStr, x, y+28); + g.setFontAlign(0, 0).setFont("6x8", 2); + g.drawString(getSteps(), 50, y+70); + g.drawString(Math.round(Bangle.getHealthStatus("last").bpm), g.getWidth() -37, y + 70); + + // queue next draw + if (drawTimeout) clearTimeout(drawTimeout); + drawTimeout = setTimeout(function() { + drawTimeout = undefined; + draw(); + }, 60000 - (Date.now() % 60000)); +}; + +// Show launcher when middle button pressed +Bangle.setUI({ + mode : "clock", + remove : function() { + // Called to unload all of the clock app + if (drawTimeout) clearTimeout(drawTimeout); + drawTimeout = undefined; + delete Graphics.prototype.setFontAnton; + }}); +// Load widgets +Bangle.loadWidgets(); +draw(); +setTimeout(Bangle.drawWidgets,0); +} diff --git a/apps/entonclk/app.png b/apps/entonclk/app.png new file mode 100644 index 000000000..5b634de5a Binary files /dev/null and b/apps/entonclk/app.png differ diff --git a/apps/entonclk/metadata.json b/apps/entonclk/metadata.json new file mode 100644 index 000000000..7e4947406 --- /dev/null +++ b/apps/entonclk/metadata.json @@ -0,0 +1,17 @@ +{ + "id": "entonclk", + "name": "Enton Clock", + "version": "0.1", + "description": "A simple clock using the Audiowide font. ", + "icon": "app.png", + "screenshots": [{"url":"screenshot.png"}], + "type": "clock", + "tags": "clock", + "supports": ["BANGLEJS2"], + "allow_emulator": true, + "readme":"README.md", + "storage": [ + {"name":"entonclk.app.js","url":"app.js"}, + {"name":"entonclk.img","url":"app-icon.js","evaluate":true} + ] +} diff --git a/apps/entonclk/screenshot.png b/apps/entonclk/screenshot.png new file mode 100644 index 000000000..0905c6fc8 Binary files /dev/null and b/apps/entonclk/screenshot.png differ diff --git a/apps/gallery/ChangeLog b/apps/gallery/ChangeLog new file mode 100644 index 000000000..76db22053 --- /dev/null +++ b/apps/gallery/ChangeLog @@ -0,0 +1,2 @@ +0.01: New app! +0.02: Submitted to app loader \ No newline at end of file diff --git a/apps/gallery/README.md b/apps/gallery/README.md new file mode 100644 index 000000000..b70fa07c2 --- /dev/null +++ b/apps/gallery/README.md @@ -0,0 +1,18 @@ +# Gallery + +A simple gallery app + +## Usage + +Upon opening the gallery app, you will be presented with a list of images that you can display. Tap the image to show it. Brightness will be set to full, and the screen timeout will be disabled. When you are done viewing the image, you can tap the screen to go back to the list of images. Press BTN1 to flip the image upside down. + +## Adding images + +1. The gallery app does not perform any scaling, and does not support panning. Therefore, you should use your favorite image editor to produce an image of the appropriate size for your watch. (240x240 for Bangle 1 or 176x176 for Bangle 2.) How you achieve this is up to you. If on a Bangle 2, I recommend adjusting the colors here to comply with the color restrictions. + +2. Upload your image to the [Espruino image converter](https://www.espruino.com/Image+Converter). I recommend enabling compression and choosing one of the following color settings: + * 16 bit RGB565 for Bangle 1 + * 3 bit RGB for Bangle 2 + * 1 bit black/white for monochrome images that you want to respond to your system theme. (White will be rendered as your foreground color and black will be rendered as your background color.) + +3. Set the output format to an image string, copy it into the [IDE](https://www.espruino.com/ide/), and set the destination to a file in storage. The file name should begin with "gal-" (without the quotes) and end with ".img" (without the quotes) to appear in the gallery. Note that the gal- prefix and .img extension will be removed in the UI. Upload the file. \ No newline at end of file diff --git a/apps/gallery/app.js b/apps/gallery/app.js new file mode 100644 index 000000000..ca9392f13 --- /dev/null +++ b/apps/gallery/app.js @@ -0,0 +1,52 @@ +const storage = require('Storage'); + +let imageFiles = storage.list(/^gal-.*\.img/).sort(); + +let imageMenu = { '': { 'title': 'Gallery' } }; + +for (let fileName of imageFiles) { + let displayName = fileName.substr(4, fileName.length - 8); // Trim off the 'gal-' and '.img' for a friendly display name + imageMenu[displayName] = eval(`() => { drawImage("${fileName}"); }`); // Unfortunately, eval is the only reasonable way to do this +} + +let cachedOptions = Bangle.getOptions(); // We will change the backlight and timeouts later, and need to restore them when displaying the menu +let backlightSetting = storage.readJSON('setting.json').brightness; // LCD brightness is not included in there for some reason + +let angle = 0; // Store the angle of rotation +let image; // Cache the image here because we access it in multiple places + +function drawMenu() { + Bangle.removeListener('touch', drawMenu); // We no longer want touching to reload the menu + Bangle.setOptions(cachedOptions); // The drawImage function set no timeout, undo that + Bangle.setLCDBrightness(backlightSetting); // Restore backlight + image = undefined; // Delete the image from memory + + E.showMenu(imageMenu); +} + +function drawImage(fileName) { + E.showMenu(); // Remove the menu to prevent it from breaking things + setTimeout(() => { Bangle.on('touch', drawMenu); }, 300); // Touch the screen to go back to the image menu (300ms timeout to allow user to lift finger) + Bangle.setOptions({ // Disable display power saving while showing the image + lockTimeout: 0, + lcdPowerTimeout: 0, + backlightTimeout: 0 + }); + Bangle.setLCDBrightness(1); // Full brightness + + image = eval(storage.read(fileName)); // Sadly, the only reasonable way to do this + g.clear().reset().drawImage(image, 88, 88, { rotate: angle }); +} + +setWatch(info => { + if (image) { + if (angle == 0) angle = Math.PI; + else angle = 0; + Bangle.buzz(); + + g.clear().reset().drawImage(image, 88, 88, { rotate: angle }) + } +}, BTN1, { repeat: true }); + +// We don't load the widgets because there is no reasonable way to unload them +drawMenu(); \ No newline at end of file diff --git a/apps/gallery/icon.js b/apps/gallery/icon.js new file mode 100644 index 000000000..11fee53eb --- /dev/null +++ b/apps/gallery/icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwgIOLgf/AAX8Av4FBJgkMAos/CIfMAv4Fe4AF/Apq5EAAw")) \ No newline at end of file diff --git a/apps/gallery/icon.png b/apps/gallery/icon.png new file mode 100644 index 000000000..71835e93d Binary files /dev/null and b/apps/gallery/icon.png differ diff --git a/apps/gallery/metadata.json b/apps/gallery/metadata.json new file mode 100644 index 000000000..89f7606aa --- /dev/null +++ b/apps/gallery/metadata.json @@ -0,0 +1,26 @@ +{ + "id": "gallery", + "name": "Gallery", + "version": "0.02", + "description": "A gallery that lets you view images uploaded with the IDE (see README)", + "readme": "README.md", + "icon": "icon.png", + "type": "app", + "tags": "tools", + "supports": [ + "BANGLEJS2", + "BANGLEJS" + ], + "allow_emulator": true, + "storage": [ + { + "name": "gallery.app.js", + "url": "app.js" + }, + { + "name": "gallery.img", + "url": "icon.js", + "evaluate": true + } + ] +} \ No newline at end of file diff --git a/apps/gpstrek/ChangeLog b/apps/gpstrek/ChangeLog index b72d3fae2..d46ada767 100644 --- a/apps/gpstrek/ChangeLog +++ b/apps/gpstrek/ChangeLog @@ -6,3 +6,5 @@ 0.05: Added adjustment for Bangle.js magnetometer heading fix 0.06: Fix waypoint menu always selecting last waypoint Fix widget adding listeners more than once +0.07: Show checkered flag for target markers + Single waypoints are now shown in the compass view diff --git a/apps/gpstrek/README.md b/apps/gpstrek/README.md index 6edad2b1d..c55f5a8bf 100644 --- a/apps/gpstrek/README.md +++ b/apps/gpstrek/README.md @@ -10,7 +10,7 @@ Tapping or button to switch to the next information display, swipe right for the Choose either a route or a waypoint as basis for the display. -After this selection and availability of a GPS fix the compass will show a blue dot for your destination and a green one for possibly available waypoints on the way. +After this selection and availability of a GPS fix the compass will show a checkered flag for your destination and a green dot for possibly available waypoints on the way. Waypoints are shown with name if available and distance to waypoint. As long as no GPS signal is available the compass shows the heading from the build in magnetometer. When a GPS fix becomes available, the compass display shows the GPS course. This can be differentiated by the display of bubble levels on top and sides of the compass. diff --git a/apps/gpstrek/app.js b/apps/gpstrek/app.js index 919f114ea..f26811ed3 100644 --- a/apps/gpstrek/app.js +++ b/apps/gpstrek/app.js @@ -239,8 +239,14 @@ function getCompassSlice(compassDataSource){ } else { bpos=Math.round(bpos*increment); } - graphics.setColor(p.color); - graphics.fillCircle(bpos,y+height-12,Math.floor(width*0.03)); + if (p.color){ + graphics.setColor(p.color); + } + if (p.icon){ + graphics.drawImage(p.icon, bpos,y+height-12, {rotate:0,scale:2}); + } else { + graphics.fillCircle(bpos,y+height-12,Math.floor(width*0.03)); + } } } if (compassDataSource.getMarkers){ @@ -595,8 +601,8 @@ function showBackgroundMenu(){ "title" : "Background", back : showMenu, }, - "Start" : ()=>{ E.showPrompt("Start?").then((v)=>{ if (v) {WIDGETS.gpstrek.start(true); removeMenu();} else {E.showMenu(mainmenu);}});}, - "Stop" : ()=>{ E.showPrompt("Stop?").then((v)=>{ if (v) {WIDGETS.gpstrek.stop(true); removeMenu();} else {E.showMenu(mainmenu);}});}, + "Start" : ()=>{ E.showPrompt("Start?").then((v)=>{ if (v) {WIDGETS.gpstrek.start(true); removeMenu();} else {showMenu();}}).catch(()=>{E.showMenu(mainmenu);});}, + "Stop" : ()=>{ E.showPrompt("Stop?").then((v)=>{ if (v) {WIDGETS.gpstrek.stop(true); removeMenu();} else {showMenu();}}).catch(()=>{E.showMenu(mainmenu);});}, }; E.showMenu(menu); } @@ -677,13 +683,15 @@ function setClosestWaypoint(route, startindex, progress){ let screen = 1; +const finishIcon = atob("CggB//meZmeZ+Z5n/w=="); + const compassSliceData = { getCourseType: function(){ return (state.currentPos && state.currentPos.course) ? "GPS" : "MAG"; }, getCourse: function (){ if(compassSliceData.getCourseType() == "GPS") return state.currentPos.course; - return state.compassHeading?360-state.compassHeading:undefined; + return state.compassHeading?state.compassHeading:undefined; }, getPoints: function (){ let points = []; @@ -691,7 +699,10 @@ const compassSliceData = { points.push({bearing:bearing(state.currentPos, state.route.currentWaypoint), color:"#0f0"}); } if (state.currentPos && state.currentPos.lon && state.route){ - points.push({bearing:bearing(state.currentPos, getLast(state.route)), color:"#00f"}); + points.push({bearing:bearing(state.currentPos, getLast(state.route)), icon: finishIcon}); + } + if (state.currentPos && state.currentPos.lon && state.waypoint){ + points.push({bearing:bearing(state.currentPos, state.waypoint), icon: finishIcon}); } return points; }, diff --git a/apps/gpstrek/metadata.json b/apps/gpstrek/metadata.json index 3895c025b..cf5d06baa 100644 --- a/apps/gpstrek/metadata.json +++ b/apps/gpstrek/metadata.json @@ -1,7 +1,7 @@ { "id": "gpstrek", "name": "GPS Trekking", - "version": "0.06", + "version": "0.07", "description": "Helper for tracking the status/progress during hiking. Do NOT depend on this for navigation!", "icon": "icon.png", "screenshots": [{"url":"screen1.png"},{"url":"screen2.png"},{"url":"screen3.png"},{"url":"screen4.png"}], diff --git a/apps/gpstrek/screen2.png b/apps/gpstrek/screen2.png index 12cd65975..9a6e14e06 100644 Binary files a/apps/gpstrek/screen2.png and b/apps/gpstrek/screen2.png differ diff --git a/apps/gpstrek/widget.js b/apps/gpstrek/widget.js index 8b6b04a78..347df2df5 100644 --- a/apps/gpstrek/widget.js +++ b/apps/gpstrek/widget.js @@ -24,7 +24,7 @@ function onGPS(fix) { } function onMag(e) { - if (!state.compassHeading) state.compassHeading = 360-e.heading; + if (!state.compassHeading) state.compassHeading = e.heading; //if (a+180)mod 360 == b then //return (a+b)/2 mod 360 and ((a+b)/2 mod 360) + 180 (they are both the solution, so you may choose one depending if you prefer counterclockwise or clockwise direction) diff --git a/apps/henkinen/ChangeLog b/apps/henkinen/ChangeLog new file mode 100644 index 000000000..5560f00bc --- /dev/null +++ b/apps/henkinen/ChangeLog @@ -0,0 +1 @@ +0.01: New App! diff --git a/apps/henkinen/README.md b/apps/henkinen/README.md new file mode 100644 index 000000000..e17e86121 --- /dev/null +++ b/apps/henkinen/README.md @@ -0,0 +1,7 @@ +# Henkinen + +By Jukio Kallio + +A tiny app helping you to breath and relax. + +![](screenshot1.png) diff --git a/apps/henkinen/app-icon.js b/apps/henkinen/app-icon.js new file mode 100644 index 000000000..7c82a375d --- /dev/null +++ b/apps/henkinen/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwkEogA0/4AKCpNPCxYAB+gtTGJQuOGBAWPGAwuQGAwXH+cykc/C6UhgMSkMQiQXKBQsgiYFDmMCMBIIEmAWEDAUDC5nzBwogDMYgXHBoohJC4wuJEQwXG+ALDmUQgMjEYcPC5MhAYXxgAACj4ICVYYXGIwXzCwYABHAUwC5HyEwXwC4pEC+MvC4/xEoUQC4sBHIQlCC4vwIxBIEGYQXFmJKCC45ECfQQXIRoiRGC5EiOxB4EBwQXdI653XU67XX+QJCPAwrC+JKCC4v/gZIIHIUwCAQXGkIDCSIg4C/8SC5PwEwX/mUQgMjAwXzJQQXH+ZICAA8wEYYXGBgoAEEQoXHGBIhFC44OBcgQADmIgFC5H/kAYEmMCBooXDp4KFkMBiUhiCjDAAX0C5RjBmUjPo4XMABQXEMAwALCwgwRFwowRCwwwPFw4xOCpIArA")) diff --git a/apps/henkinen/app.js b/apps/henkinen/app.js new file mode 100644 index 000000000..d7c7bd5ed --- /dev/null +++ b/apps/henkinen/app.js @@ -0,0 +1,127 @@ +// Henkinen +// +// Bangle.js 2 breathing helper +// by Jukio Kallio +// www.jukiokallio.com + +require("FontHaxorNarrow7x17").add(Graphics); + +// settings +const breath = { + theme: "default", + x:0, y:0, w:0, h:0, + size: 60, + + bgcolor: g.theme.bg, + incolor: g.theme.fg, + keepcolor: g.theme.fg, + outcolor: g.theme.fg, + + font: "HaxorNarrow7x17", fontsize: 1, + textcolor: g.theme.fg, + texty: 18, + + in: 4000, + keep: 7000, + out: 8000 +}; + +// set some additional settings +breath.w = g.getWidth(); // size of the background +breath.h = g.getHeight(); +breath.x = breath.w * 0.5; // position of the circles +breath.y = breath.h * 0.45; +breath.texty = breath.y + breath.size + breath.texty; // text position + +var wait = 100; // wait time, normally a minute +var time = 0; // for time keeping + + +// timeout used to update every minute +var drawTimeout; + +// schedule a draw for the next minute +function queueDraw() { + if (drawTimeout) clearTimeout(drawTimeout); + drawTimeout = setTimeout(function() { + drawTimeout = undefined; + draw(); + }, wait - (Date.now() % wait)); +} + + +// main function +function draw() { + // make date object + var date = new Date(); + + // update current time + time += wait - (Date.now() % wait); + if (time > breath.in + breath.keep + breath.out) time = 0; // reset time + + // Reset the state of the graphics library + g.reset(); + + // Clear the area where we want to draw the time + g.setColor(breath.bgcolor); + g.fillRect(0, 0, breath.w, breath.h); + + // calculate circle size + var circle = 0; + if (time < breath.in) { + // breath in + circle = time / breath.in; + g.setColor(breath.incolor); + + } else if (time < breath.in + breath.keep) { + // keep breath + circle = 1; + g.setColor(breath.keepcolor); + + } else if (time < breath.in + breath.keep + breath.out) { + // breath out + circle = ((breath.in + breath.keep + breath.out) - time) / breath.out; + g.setColor(breath.outcolor); + + } + + // draw breath circle + g.fillCircle(breath.x, breath.y, breath.size * circle); + + // breath area + g.setColor(breath.textcolor); + g.drawCircle(breath.x, breath.y, breath.size); + + // draw text + g.setFontAlign(0,0).setFont(breath.font, breath.fontsize).setColor(breath.textcolor); + + if (time < breath.in) { + // breath in + g.drawString("Breath in", breath.x, breath.texty); + + } else if (time < breath.in + breath.keep) { + // keep breath + g.drawString("Keep it in", breath.x, breath.texty); + + } else if (time < breath.in + breath.keep + breath.out) { + // breath out + g.drawString("Breath out", breath.x, breath.texty); + + } + + // queue draw + queueDraw(); +} + + +// Clear the screen once, at startup +g.clear(); +// draw immediately at first +draw(); + + +// keep LCD on +Bangle.setLCDPower(1); + +// Show launcher when middle button pressed +Bangle.setUI("clock"); diff --git a/apps/henkinen/app.png b/apps/henkinen/app.png new file mode 100644 index 000000000..575ecbcd4 Binary files /dev/null and b/apps/henkinen/app.png differ diff --git a/apps/henkinen/metadata.json b/apps/henkinen/metadata.json new file mode 100644 index 000000000..1f1bb77fc --- /dev/null +++ b/apps/henkinen/metadata.json @@ -0,0 +1,15 @@ +{ "id": "henkinen", + "name": "Henkinen - Tiny Breathing Helper", + "shortName":"Henkinen", + "version":"0.01", + "description": "A tiny app helping you to breath and relax.", + "icon": "app.png", + "screenshots": [{"url":"screenshot1.png"}], + "tags": "outdoors", + "supports" : ["BANGLEJS","BANGLEJS2"], + "readme": "README.md", + "storage": [ + {"name":"henkinen.app.js","url":"app.js"}, + {"name":"henkinen.img","url":"app-icon.js","evaluate":true} + ] +} diff --git a/apps/henkinen/screenshot1.png b/apps/henkinen/screenshot1.png new file mode 100644 index 000000000..938494673 Binary files /dev/null and b/apps/henkinen/screenshot1.png differ diff --git a/apps/iconlaunch/ChangeLog b/apps/iconlaunch/ChangeLog index eca18b06c..b0fcaf856 100644 --- a/apps/iconlaunch/ChangeLog +++ b/apps/iconlaunch/ChangeLog @@ -8,3 +8,9 @@ Add swipe-to-exit 0.08: Only use fast loading for switching to clock to prevent problems in full screen apps 0.09: Remove fast load option since clocks containing Bangle.loadWidgets are now always normally loaded +0.10: changed the launch.json file name in iconlaunch.json ( launch.cache.json -> iconlaunch.cache.json) + used Object.assing for the settings + fix cache not deleted when "showClocks" options is changed + added timeOut to return to the clock +0.11: Cleanup timeout when changing to clock + Reset timeout on swipe and drag diff --git a/apps/iconlaunch/app.js b/apps/iconlaunch/app.js index 8d2f1ec4c..a7b89996a 100644 --- a/apps/iconlaunch/app.js +++ b/apps/iconlaunch/app.js @@ -1,12 +1,20 @@ { const s = require("Storage"); - const settings = s.readJSON("launch.json", true) || { showClocks: true, fullscreen: false,direct:false,swipeExit:false,oneClickExit:false}; + const settings = Object.assign({ + showClocks: true, + fullscreen: false, + direct: false, + oneClickExit: false, + swipeExit: false, + timeOut:"Off" + }, s.readJSON("iconlaunch.json", true) || {}); + if (!settings.fullscreen) { Bangle.loadWidgets(); Bangle.drawWidgets(); } - let launchCache = s.readJSON("launch.cache.json", true)||{}; - let launchHash = require("Storage").hash(/\.info/); + let launchCache = s.readJSON("iconlaunch.cache.json", true)||{}; + let launchHash = s.hash(/\.info/); if (launchCache.hash!=launchHash) { launchCache = { hash : launchHash, @@ -20,7 +28,7 @@ if (a.name>b.name) return 1; return 0; }) }; - s.writeJSON("launch.cache.json", launchCache); + s.writeJSON("iconlaunch.cache.json", launchCache); } let scroll = 0; let selectedItem = -1; @@ -124,6 +132,7 @@ g.flip(); const itemsN = Math.ceil(launchCache.apps.length / appsN); let onDrag = function(e) { + updateTimeout(); g.setColor(g.theme.fg); g.setBgColor(g.theme.bg); let dy = e.dy; @@ -173,6 +182,7 @@ drag: onDrag, touch: (_, e) => { if (e.y < R.y - 4) return; + updateTimeout(); let i = YtoIdx(e.y); selectItem(i, e); }, @@ -193,11 +203,23 @@ delete idxToY; delete YtoIdx; delete settings; + if (timeout) clearTimeout(timeout); setTimeout(eval, 0, s.read(".bootcde")); }; if (settings.oneClickExit) mode.btn = returnToClock; + let timeout; + const updateTimeout = function(){ + if (settings.timeOut!="Off"){ + let time=parseInt(settings.timeOut); //the "s" will be trimmed by the parseInt + if (timeout) clearTimeout(timeout); + timeout = setTimeout(returnToClock,time*1000); + } + } + + updateTimeout(); + Bangle.setUI(mode); } diff --git a/apps/iconlaunch/metadata.json b/apps/iconlaunch/metadata.json index e310ede4d..8154d830a 100644 --- a/apps/iconlaunch/metadata.json +++ b/apps/iconlaunch/metadata.json @@ -2,7 +2,7 @@ "id": "iconlaunch", "name": "Icon Launcher", "shortName" : "Icon launcher", - "version": "0.09", + "version": "0.11", "icon": "app.png", "description": "A launcher inspired by smartphones, with an icon-only scrollable menu.", "tags": "tool,system,launcher", @@ -12,6 +12,7 @@ { "name": "iconlaunch.app.js", "url": "app.js" }, { "name": "iconlaunch.settings.js", "url": "settings.js" } ], + "data": [{"name":"iconlaunch.json"},{"name":"iconlaunch.cache.json"}], "screenshots": [{ "url": "screenshot1.png" }, { "url": "screenshot2.png" }], "readme": "README.md" } diff --git a/apps/iconlaunch/settings.js b/apps/iconlaunch/settings.js index 49a49f700..f4c0599f7 100644 --- a/apps/iconlaunch/settings.js +++ b/apps/iconlaunch/settings.js @@ -1,24 +1,29 @@ // make sure to enclose the function in parentheses (function(back) { + const s = require("Storage"); let settings = Object.assign({ showClocks: true, fullscreen: false, direct: false, oneClickExit: false, - swipeExit: false - }, require("Storage").readJSON("launch.json", true) || {}); + swipeExit: false, + timeOut:"Off" + }, s.readJSON("iconlaunch.json", true) || {}); - let fonts = g.getFonts(); function save(key, value) { settings[key] = value; - require("Storage").write("launch.json",settings); + s.write("iconlaunch.json",settings); } + const timeOutChoices = [/*LANG*/"Off", "10s", "15s", "20s", "30s"]; const appMenu = { "": { "title": /*LANG*/"Launcher" }, /*LANG*/"< Back": back, /*LANG*/"Show Clocks": { value: settings.showClocks == true, - onchange: (m) => { save("showClocks", m) } + onchange: (m) => { + save("showClocks", m); + s.erase("iconlaunch.cache.json"); //delete the cache app list + } }, /*LANG*/"Fullscreen": { value: settings.fullscreen == true, @@ -35,7 +40,15 @@ /*LANG*/"Swipe exit": { value: settings.swipeExit == true, onchange: m => { save("swipeExit", m) } - } + }, + /*LANG*/'Time Out': { + value: timeOutChoices.indexOf(settings.timeOut), + min: 0, max: timeOutChoices.length-1, + format: v => timeOutChoices[v], + onchange: m => { + save("timeOut", timeOutChoices[m]); + } + }, }; E.showMenu(appMenu); }); diff --git a/apps/imageclock/ChangeLog b/apps/imageclock/ChangeLog index f81bbf185..d681176a7 100644 --- a/apps/imageclock/ChangeLog +++ b/apps/imageclock/ChangeLog @@ -12,3 +12,7 @@ 0.10: Fix clock not correctly refreshing when drawing in timeouts option is not on 0.11: Additional option in customizer to force drawing directly Fix some problems in handling timeouts +0.12: Use widget_utils module + Fix colorsetting in promises in generated code + Some performance improvements by caching lookups + Activate UI after first draw is complete to prevent drawing over launcher diff --git a/apps/imageclock/app.js b/apps/imageclock/app.js index ff3f5a62d..3d7a830d2 100644 --- a/apps/imageclock/app.js +++ b/apps/imageclock/app.js @@ -202,27 +202,39 @@ let firstDraw = true; let firstDigitY = element.Y; let imageIndex = element.ImageIndex ? element.ImageIndex : 0; - let firstImage; - if (imageIndex){ - firstImage = getByPath(resources, [], "" + (0 + imageIndex)); - } else { - firstImage = getByPath(resources, element.ImagePath, 0); + let firstImage = element.cachedFirstImage; + if (!firstImage && !element.cachedFirstImageMissing){ + if (imageIndex){ + firstImage = getByPath(resources, [], "" + (0 + imageIndex)); + } else { + firstImage = getByPath(resources, element.ImagePath, 0); + } + element.cachedFirstImage = firstImage; + if (!firstImage) element.cachedFirstImageMissing = true; } - let minusImage; - if (imageIndexMinus){ - minusImage = getByPath(resources, [], "" + (0 + imageIndexMinus)); - } else { - minusImage = getByPath(resources, element.ImagePath, "minus"); + let minusImage = element.cachedMinusImage; + if (!minusImage && !element.cachedMinusImageMissing){ + if (imageIndexMinus){ + minusImage = getByPath(resources, [], "" + (0 + imageIndexMinus)); + } else { + minusImage = getByPath(resources, element.ImagePath, "minus"); + } + element.cachedMinusImage = minusImage; + if (!minusImage) element.cachedMinusImageMissing = true; } - let unitImage; + let unitImage = element.cachedUnitImage; //print("Get image for unit", imageIndexUnit); - if (imageIndexUnit !== undefined){ - unitImage = getByPath(resources, [], "" + (0 + imageIndexUnit)); - //print("Unit image is", unitImage); - } else if (element.Unit){ - unitImage = getByPath(resources, element.ImagePath, getMultistate(element.Unit, "unknown")); + if (!unitImage && !element.cachedUnitImageMissing){ + if (imageIndexUnit !== undefined){ + unitImage = getByPath(resources, [], "" + (0 + imageIndexUnit)); + //print("Unit image is", unitImage); + } else if (element.Unit){ + unitImage = getByPath(resources, element.ImagePath, getMultistate(element.Unit, "unknown")); + } + unitImage = element.cachedUnitImage; + if (!unitImage) element.cachedUnitImageMissing = true; } let numberWidth = (numberOfDigits * firstImage.width) + (Math.max((numberOfDigits - 1),0) * spacing); @@ -292,14 +304,7 @@ let firstDraw = true; if (resource){ prepareImg(resource); //print("lastElem", typeof resource) - if (resource) { - element.cachedImage[cacheKey] = resource; - //print("cache res ",typeof element.cachedImage[cacheKey]); - } else { - element.cachedImage[cacheKey] = null; - //print("cache null",typeof element.cachedImage[cacheKey]); - //print("Could not create image from", resource); - } + element.cachedImage[cacheKey] = resource; } else { //print("Could not get resource from", element, lastElem); } @@ -604,18 +609,15 @@ let firstDraw = true; promise.then(()=>{ let currentDrawingTime = Date.now(); - if (showWidgets && global.WIDGETS){ - //print("Draw widgets"); + if (showWidgets){ restoreWidgetDraw(); - Bangle.drawWidgets(); - g.setColor(g.theme.fg); - g.drawLine(0,24,g.getWidth(),24); } lastDrawTime = Date.now() - start; isDrawing=false; firstDraw=false; requestRefresh = false; endPerfLog("initialDraw"); + if (!Bangle.uiRemove) setUi(); }).catch((e)=>{ print("Error during drawing", e); }); @@ -751,30 +753,19 @@ let firstDraw = true; let showWidgetsChanged = false; - let currentDragDistance = 0; let restoreWidgetDraw = function(){ - if (global.WIDGETS) { - for (let w in global.WIDGETS) { - let wd = global.WIDGETS[w]; - wd.draw = originalWidgetDraw[w]; - wd.area = originalWidgetArea[w]; - } - } + require("widget_utils").show(); + Bangle.drawWidgets(); }; - - let handleDrag = function(e){ - //print("handleDrag"); - currentDragDistance += e.dy; - if (Math.abs(currentDragDistance) < 10) return; - dragDown = currentDragDistance > 0; - currentDragDistance = 0; - if (!showWidgets && dragDown){ + + let handleSwipe = function(lr, ud){ + if (!showWidgets && ud == 1){ //print("Enable widgets"); restoreWidgetDraw(); showWidgetsChanged = true; } - if (showWidgets && !dragDown){ + if (showWidgets && ud == -1){ //print("Disable widgets"); clearWidgetsDraw(); firstDraw = true; @@ -783,12 +774,12 @@ let firstDraw = true; if (showWidgetsChanged){ showWidgetsChanged = false; //print("Draw after widget change"); - showWidgets = dragDown; + showWidgets = ud == 1; initialDraw(); } }; - Bangle.on('drag', handleDrag); + Bangle.on('swipe', handleSwipe); if (!events || events.includes("pressure")){ Bangle.on('pressure', handlePressure); @@ -814,62 +805,54 @@ let firstDraw = true; let clearWidgetsDraw = function(){ //print("Clear widget draw calls"); - if (global.WIDGETS) { - originalWidgetDraw = {}; - originalWidgetArea = {}; - for (let w in global.WIDGETS) { - let wd = global.WIDGETS[w]; - originalWidgetDraw[w] = wd.draw; - originalWidgetArea[w] = wd.area; - wd.draw = () => {}; - wd.area = ""; - } - } + require("widget_utils").hide(); } handleLock(Bangle.isLocked(), true); - Bangle.setUI({ - mode : "clock", - remove : function() { - //print("remove calls"); - // Called to unload all of the clock app - Bangle.setHRMPower(0, "imageclock"); - Bangle.setBarometerPower(0, 'imageclock'); + let setUi = function(){ + Bangle.setUI({ + mode : "clock", + remove : function() { + //print("remove calls"); + // Called to unload all of the clock app + Bangle.setHRMPower(0, "imageclock"); + Bangle.setBarometerPower(0, 'imageclock'); - Bangle.removeListener('drag', handleDrag); - Bangle.removeListener('lock', handleLock); - Bangle.removeListener('charging', handleCharging); - Bangle.removeListener('HRM', handleHrm); - Bangle.removeListener('pressure', handlePressure); + Bangle.removeListener('swipe', handleSwipe); + Bangle.removeListener('lock', handleLock); + Bangle.removeListener('charging', handleCharging); + Bangle.removeListener('HRM', handleHrm); + Bangle.removeListener('pressure', handlePressure); - if (deferredTimout) clearTimeout(deferredTimout); - if (initialDrawTimeoutUnlocked) clearTimeout(initialDrawTimeoutUnlocked); - if (initialDrawTimeoutLocked) clearTimeout(initialDrawTimeoutLocked); + if (deferredTimout) clearTimeout(deferredTimout); + if (initialDrawTimeoutUnlocked) clearTimeout(initialDrawTimeoutUnlocked); + if (initialDrawTimeoutLocked) clearTimeout(initialDrawTimeoutLocked); - for (let i of unlockedDrawInterval){ - //print("Clearing unlocked", i); - clearInterval(i); + for (let i of global.unlockedDrawInterval){ + //print("Clearing unlocked", i); + clearInterval(i); + } + delete global.unlockedDrawInterval; + for (let i of global.lockedDrawInterval){ + //print("Clearing locked", i); + clearInterval(i); + } + delete global.lockedDrawInterval; + delete global.showWidgets; + delete global.firstDraw; + + delete Bangle.printPerfLog; + if (settings.perflog){ + delete Bangle.resetPerfLog; + delete performanceLog; + } + + cleanupDelays(); + restoreWidgetDraw(); } - delete unlockedDrawInterval; - for (let i of lockedDrawInterval){ - //print("Clearing locked", i); - clearInterval(i); - } - delete lockedDrawInterval; - delete showWidgets; - delete firstDraw; - - delete Bangle.printPerfLog; - if (settings.perflog){ - delete Bangle.resetPerfLog; - delete performanceLog; - } - - cleanupDelays(); - restoreWidgetDraw(); - } - }); + }); + } Bangle.loadWidgets(); clearWidgetsDraw(); diff --git a/apps/imageclock/custom.html b/apps/imageclock/custom.html index e595b51ca..8bf7a7bd0 100644 --- a/apps/imageclock/custom.html +++ b/apps/imageclock/custom.html @@ -714,13 +714,13 @@ } if (addDebug()) code += 'print("Element condition is ' + condition + '");' + "\n"; - code += "" + colorsetting; code += (condition.length > 0 ? "if (" + condition + "){\n" : ""); if (wrapInTimeouts && (plane != 0 || forceUseOrigPlane)){ code += "p = p.then(()=>delay(0)).then(()=>{\n"; } else { code += "p = p.then(()=>{\n"; } + code += "" + colorsetting; if (addDebug()) code += 'print("Drawing element ' + elementIndex + ' with type ' + c.type + ' on plane ' + planeName + '");' + "\n"; code += "draw" + c.type + "(" + planeName + ", wr, wf.Collapsed[" + elementIndex + "].value);\n"; diff --git a/apps/imageclock/metadata.json b/apps/imageclock/metadata.json index e068b9fa7..51257b435 100644 --- a/apps/imageclock/metadata.json +++ b/apps/imageclock/metadata.json @@ -2,7 +2,7 @@ "id": "imageclock", "name": "Imageclock", "shortName": "Imageclock", - "version": "0.11", + "version": "0.12", "type": "clock", "description": "BETA!!! File formats still subject to change --- This app is a highly customizable watchface. To use it, you need to select a watchface. You can build the watchfaces yourself without programming anything. All you need to do is write some json and create image files.", "icon": "app.png", diff --git a/apps/infoclk/ChangeLog b/apps/infoclk/ChangeLog new file mode 100644 index 000000000..4744f872a --- /dev/null +++ b/apps/infoclk/ChangeLog @@ -0,0 +1,3 @@ +0.01: New app! +0.02-0.07: Bug fixes +0.08: Submitted to the app loader \ No newline at end of file diff --git a/apps/infoclk/README.md b/apps/infoclk/README.md new file mode 100644 index 000000000..1dd563bec --- /dev/null +++ b/apps/infoclk/README.md @@ -0,0 +1,33 @@ +# Informational clock + +A configurable clock with extra info and shortcuts when unlocked, but large time when locked + +## Information + +The clock has two different screen arrangements, depending on whether the watch is locked or unlocked. The most commonly viewed piece of information is the time, so when the watch is locked it optimizes for the time being visible at a glance without the backlight. The hours and minutes take up nearly the entire top half of the display, with the date and seconds taking up nearly the entire bottom half. The day progress bar is between them if enabled, unless configured to be on the bottom row. The bottom row can be configured to display a weather summary, step count, step count and heart rate, the daily progress bar, or nothing. + +When the watch is unlocked, it can be assumed that the backlight is on and the user is actively looking at the watch, so instead we can optimize for information density. The bottom half of the display becomes shortcuts, and the top half of the display becomes 4 rows of information (date and time, step count and heart rate, 2 line weather summary) + an optional daily progress bar. (The daily progress bar can be independently enabled when locked and unlocked.) + +Most things are self-explanatory, but the day progress bar might not be. The day progress bar is intended to show approximately how far through the day you are, in the form of a progress bar. You might want to configure it to show how far you are through your waking hours, or you might want to use it to show how far you are through your work or school day. + +## Shortcuts + +There are generally a few apps that the user uses far more frequently than the others. For example, they might use a timer, alarm clock, and calculator every day, while everything else (such as the settings app) gets used only occasionally. This clock has space for 8 apps in the bottom half of the screen only one tap away, avoiding the need to wait for the launcher to open and then scroll through it. Tapping the top of the watch opens the launcher, eliminating the need for the button (which still opens the launcher due to bangle.js conventions). There is also handling for left, right, and vertical swipes. A vertical swipe by default opens the messages app, mimicking mobile operating systems which use a swipe down to view the notification shade. + +## Configurability + +Displaying the seconds allows for more precise timing, but waking up the CPU to refresh the display more often consumes battery. The user can enable or disable them completely, but can also configure them to be enabled or disabled automatically based on some hueristics: + +* They can be hidden while the display is locked, if the user expects to unlock their watch when they need the seconds. +* They can be hidden when the battery is too low, to make the last portion of the battery last a little bit longer. +* They can be hidden during a period of time such as when the user is asleep and therefore unlikely to need very much precision. + +The date format can be changed. + +As described earlier, the contents of the bottom row when locked can be changed. + +The 8 tap-based shortcuts on the bottom and the 3 swipe-based shortcuts can be changed to nothing, the launcher, or any app on the watch. + +The start and end time of the day progress bar can be changed. It can be enabled or disabled separately when the watch is locked and unlocked. The color can be changed. The time when it resets from full to empty can be changed. + +When the battery is below a defined point, the watch's color can change to another chosen color to help the user notice that the battery is low. \ No newline at end of file diff --git a/apps/infoclk/app.js b/apps/infoclk/app.js new file mode 100644 index 000000000..3d51191df --- /dev/null +++ b/apps/infoclk/app.js @@ -0,0 +1,405 @@ +const SETTINGS_FILE = "infoclk.json"; +const FONT = require('infoclk-font.js'); + +const storage = require("Storage"); +const locale = require("locale"); +const weather = require('weather'); + +let config = Object.assign({ + seconds: { + // Displaying the seconds can reduce battery life because the CPU must wake up more often to update the display. + // The seconds will be shown unless one of these conditions is enabled here, and currently true. + hideLocked: false, // Hide the seconds when the display is locked. + hideBattery: 20, // Hide the seconds when the battery is at or below a defined percentage. + hideTime: true, // Hide the seconds when between a certain period of time. Useful for when you are sleeping and don't need the seconds + hideStart: 2200, // The time when the seconds are hidden: first 2 digits are hours on a 24 hour clock, last 2 are minutes + hideEnd: 700, // The time when the seconds are shown again + hideAlways: false, // Always hide (never show) the seconds + }, + + date: { + // Settings related to the display of the date + mmdd: true, // If true, display the month first. If false, display the date first. + separator: '-', // The character that goes between the month and date + monthName: false, // If false, display the month as a number. If true, display the name. + monthFullName: false, // If displaying the name: If false, display an abbreviation. If true, display a full name. + dayFullName: false, // If false, display the day of the week's abbreviation. If true, display the full name. + }, + + bottomLocked: { + display: 'weather' // What to display in the bottom row when locked: + // 'weather': The current temperature and weather description + // 'steps': Step count + // 'health': Step count and bpm + // 'progress': Day progress bar + // false: Nothing + }, + + shortcuts: [ + //8 shortcuts, displayed in the bottom half of the screen (2 rows of 4 shortcuts) when unlocked + // false = no shortcut + // '#LAUNCHER' = open the launcher + // any other string = name of app to open + 'stlap', 'keytimer', 'pomoplus', 'alarm', + 'rpnsci', 'calendar', 'torch', 'weather' + ], + + swipe: { + // 3 shortcuts to launch upon swiping: + // false = no shortcut + // '#LAUNCHER' = open the launcher + // any other string = name of app to open + up: 'messages', // Swipe up or swipe down, due to limitation of event handler + left: '#LAUNCHER', + right: '#LAUNCHER', + }, + + dayProgress: { + // A progress bar representing how far through the day you are + enabledLocked: true, // Whether this bar is enabled when the watch is locked + enabledUnlocked: false, // Whether the bar is enabled when the watch is unlocked + color: [0, 0, 1], // The color of the bar + start: 700, // The time of day that the bar starts filling + end: 2200, // The time of day that the bar becomes full + reset: 300 // The time of day when the progress bar resets from full to empty + }, + + lowBattColor: { + // The text can change color to indicate that the battery is low + level: 20, // The percentage where this happens + color: [1, 0, 0] // The color that the text changes to + } +}, storage.readJSON(SETTINGS_FILE)); + +// Return whether the given time (as a date object) is between start and end (as a number where the first 2 digits are hours on a 24 hour clock and the last 2 are minutes), with end time wrapping to next day if necessary +function timeInRange(start, time, end) { + + // Convert the given date object to a time number + let timeNumber = time.getHours() * 100 + time.getMinutes(); + + // Normalize to prevent the numbers from wrapping around at midnight + if (end <= start) { + end += 2400; + if (timeNumber < start) timeNumber += 2400; + } + + return start <= timeNumber && timeNumber <= end; +} + +// Return whether settings should be displayed based on the user's configuration +function shouldDisplaySeconds(now) { + return !( + (config.seconds.hideAlways) || + (config.seconds.hideLocked && Bangle.isLocked()) || + (E.getBattery() <= config.seconds.hideBattery) || + (config.seconds.hideTime && timeInRange(config.seconds.hideStart, now, config.seconds.hideEnd)) + ); +} + +// Determine the font size needed to fit a string of the given length widthin maxWidth number of pixels, clamped between minSize and maxSize +function getFontSize(length, maxWidth, minSize, maxSize) { + let size = Math.floor(maxWidth / length); //Number of pixels of width available to character + size *= (20 / 12); //Convert to height, assuming 20 pixels of height for every 12 of width + + // Clamp to within range + if (size < minSize) return minSize; + else if (size > maxSize) return maxSize; + else return Math.floor(size); +} + +// Get the current day of the week according to user settings +function getDayString(now) { + if (config.date.dayFullName) return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][now.getDay()]; + else return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][now.getDay()]; +} + +// Pad a number with zeros to be the given number of digits +function pad(number, digits) { + let result = '' + number; + while (result.length < digits) result = '0' + result; + return result; +} + +// Get the current date formatted according to the user settings +function getDateString(now) { + let month; + if (!config.date.monthName) month = pad(now.getMonth() + 1, 2); + else if (config.date.monthFullName) month = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][now.getMonth()]; + else month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][now.getMonth()]; + + if (config.date.mmdd) return `${month}${config.date.separator}${pad(now.getDate(), 2)}`; + else return `${pad(now.getDate(), 2)}${config.date.separator}${month}`; +} + +// Get a floating point number from 0 to 1 representing how far between the user-defined start and end points we are +function getDayProgress(now) { + let start = config.dayProgress.start; + let current = now.getHours() * 100 + now.getMinutes(); + let end = config.dayProgress.end; + let reset = config.dayProgress.reset; + + // Normalize + if (end <= start) end += 2400; + if (current < start) current += 2400; + if (reset < start) reset += 2400; + + // Convert an hhmm number into a floating-point hours + function toDecimalHours(time) { + let hours = Math.floor(time / 100); + let minutes = time % 100; + + return hours + (minutes / 60); + } + + start = toDecimalHours(start); + current = toDecimalHours(current); + end = toDecimalHours(end); + reset = toDecimalHours(reset); + + let progress = (current - start) / (end - start); + + if (progress < 0 || progress > 1) { + if (current < reset) return 1; + else return 0; + } else { + return progress; + } +} + +// Get a Gadgetbridge weather string +function getWeatherString() { + let current = weather.get(); + if (current) return locale.temp(current.temp - 273.15) + ', ' + current.txt; + else return 'Weather unknown!'; +} + +// Get a second weather row showing humidity, wind speed, and wind direction +function getWeatherRow2() { + let current = weather.get(); + if (current) return `${current.hum}%, ${locale.speed(current.wind)} ${current.wrose}`; + else return 'Check Gadgetbridge'; +} + +// Get a step string +function getStepsString() { + return '' + Bangle.getHealthStatus('day').steps + ' steps'; +} + +// Get a health string including daily steps and recent bpm +function getHealthString() { + return `${Bangle.getHealthStatus('day').steps} steps ${Bangle.getHealthStatus('last').bpm} bpm`; +} + +// Set the next timeout to draw the screen +let drawTimeout; +function setNextDrawTimeout() { + if (drawTimeout) { + clearTimeout(drawTimeout); + drawTimeout = undefined; + } + + let time; + let now = new Date(); + if (shouldDisplaySeconds(now)) time = 1000 - (now.getTime() % 1000); + else time = 60000 - (now.getTime() % 60000); + + drawTimeout = setTimeout(draw, time); +} + + +const DIGIT_WIDTH = 40; // How much width is allocated for each digit, 37 pixels + 3 pixels of space (which will go off of the screen on the right edge) +const COLON_WIDTH = 19; // How much width is allocated for the colon, 16 pixels + 3 pixels of space +const HHMM_TOP = 27; // 24 pixels for widgets + 3 pixels of space +const DIGIT_HEIGHT = 64; // How tall the digits are + +const SECONDS_TOP = HHMM_TOP + DIGIT_HEIGHT + 3; // The top edge of the seconds, top of hours and minutes + digit height + space +const SECONDS_LEFT = 2 * DIGIT_WIDTH + COLON_WIDTH; // The left edge of the seconds: displayed after 2 digits and the colon +const DATE_LETTER_HEIGHT = DIGIT_HEIGHT / 2; // Each letter of the day of week and date will be half the height of the time digits + +const DATE_CENTER_X = SECONDS_LEFT / 2; // Day of week and date will be centered between left edge of screen and where seconds start +const DOW_CENTER_Y = SECONDS_TOP + (DATE_LETTER_HEIGHT / 2); // Day of week will be the top row +const DATE_CENTER_Y = DOW_CENTER_Y + DATE_LETTER_HEIGHT; // Date will be the bottom row +const DOW_DATE_CENTER_Y = SECONDS_TOP + (DIGIT_HEIGHT / 2); // When displaying both on one row, center it +const BOTTOM_CENTER_Y = ((SECONDS_TOP + DIGIT_HEIGHT + 3) + g.getHeight()) / 2; + +// Draw the clock +function draw() { + //Prepare to draw + g.reset() + .setFontAlign(0, 0); + + if (E.getBattery() <= config.lowBattColor.level) { + let color = config.lowBattColor.color; + g.setColor(color[0], color[1], color[2]); + } + now = new Date(); + + if (Bangle.isLocked()) { //When the watch is locked + g.clearRect(0, 24, g.getWidth(), g.getHeight()); + + //Draw the hours and minutes + let x = 0; + + for (let digit of locale.time(now, 1)) { //apparently this is how you get an hh:mm time string adjusting for the user's 12/24 hour preference + if (digit != ' ') g.drawImage(FONT[digit], x, HHMM_TOP); + if (digit == ':') x += COLON_WIDTH; + else x += DIGIT_WIDTH; + } + if (storage.readJSON('setting.json')['12hour']) g.drawImage(FONT[(now.getHours() < 12) ? 'am' : 'pm'], 0, HHMM_TOP); + + //Draw the seconds if necessary + if (shouldDisplaySeconds(now)) { + let tens = Math.floor(now.getSeconds() / 10); + let ones = now.getSeconds() % 10; + g.drawImage(FONT[tens], SECONDS_LEFT, SECONDS_TOP) + .drawImage(FONT[ones], SECONDS_LEFT + DIGIT_WIDTH, SECONDS_TOP); + + // Draw the day of week and date assuming the seconds are displayed + + g.setFont('Vector', getFontSize(getDayString(now).length, SECONDS_LEFT, 6, DATE_LETTER_HEIGHT)) + .drawString(getDayString(now), DATE_CENTER_X, DOW_CENTER_Y) + .setFont('Vector', getFontSize(getDateString(now).length, SECONDS_LEFT, 6, DATE_LETTER_HEIGHT)) + .drawString(getDateString(now), DATE_CENTER_X, DATE_CENTER_Y); + + } else { + //Draw the day of week and date without the seconds + + let string = getDayString(now) + ' ' + getDateString(now); + g.setFont('Vector', getFontSize(string.length, g.getWidth(), 6, DATE_LETTER_HEIGHT)) + .drawString(string, g.getWidth() / 2, DOW_DATE_CENTER_Y); + } + + // Draw the bottom area + if (config.bottomLocked.display == 'progress') { + let color = config.dayProgress.color; + g.setColor(color[0], color[1], color[2]) + .fillRect(0, SECONDS_TOP + DIGIT_HEIGHT + 3, g.getWidth() * getDayProgress(now), g.getHeight()); + } else { + let bottomString; + + if (config.bottomLocked.display == 'weather') bottomString = getWeatherString(); + else if (config.bottomLocked.display == 'steps') bottomString = getStepsString(); + else if (config.bottomLocked.display == 'health') bottomString = getHealthString(); + else bottomString = ' '; + + g.setFont('Vector', getFontSize(bottomString.length, 176, 6, g.getHeight() - (SECONDS_TOP + DIGIT_HEIGHT + 3))) + .drawString(bottomString, g.getWidth() / 2, BOTTOM_CENTER_Y); + } + + // Draw the day progress bar between the rows if necessary + if (config.dayProgress.enabledLocked && config.bottomLocked.display != 'progress') { + let color = config.dayProgress.color; + g.setColor(color[0], color[1], color[2]) + .fillRect(0, HHMM_TOP + DIGIT_HEIGHT, g.getWidth() * getDayProgress(now), SECONDS_TOP); + } + } else { + + //If the watch is unlocked + g.clearRect(0, 24, g.getWidth(), g.getHeight() / 2); + rows = [ + `${getDayString(now)} ${getDateString(now)} ${locale.time(now, 1)}`, + getHealthString(), + getWeatherString(), + getWeatherRow2() + ]; + if (shouldDisplaySeconds(now)) rows[0] += ':' + pad(now.getSeconds(), 2); + if (storage.readJSON('setting.json')['12hour']) rows[0] += ((now.getHours() < 12) ? ' AM' : ' PM'); + + let maxHeight = ((g.getHeight() / 2) - HHMM_TOP) / (config.dayProgress.enabledUnlocked ? (rows.length + 1) : rows.length); + + let y = HHMM_TOP + maxHeight / 2; + for (let row of rows) { + let size = getFontSize(row.length, g.getWidth(), 6, maxHeight); + g.setFont('Vector', size) + .drawString(row, g.getWidth() / 2, y); + y += maxHeight; + } + + if (config.dayProgress.enabledUnlocked) { + let color = config.dayProgress.color; + g.setColor(color[0], color[1], color[2]) + .fillRect(0, y - maxHeight / 2, 176 * getDayProgress(now), y + maxHeight / 2); + } + } + + setNextDrawTimeout(); +} + +// Draw the icons. This is done separately from the main draw routine to avoid having to scale and draw a bunch of images repeatedly. +function drawIcons() { + g.reset().clearRect(0, 24, g.getWidth(), g.getHeight()); + for (let i = 0; i < 8; i++) { + let x = [0, 44, 88, 132, 0, 44, 88, 132][i]; + let y = [88, 88, 88, 88, 132, 132, 132, 132][i]; + let appId = config.shortcuts[i]; + let appInfo = storage.readJSON(appId + '.info', 1); + if (!appInfo) continue; + icon = storage.read(appInfo.icon); + g.drawImage(icon, x, y, { + scale: 0.916666666667 + }); + } +} + +weather.on("update", draw); +Bangle.on("step", draw); +Bangle.on('lock', locked => { + //If the watch is unlocked, draw the icons + if (!locked) drawIcons(); + draw(); +}); + +// Show launcher when middle button pressed +Bangle.setUI("clock"); +// Load widgets +Bangle.loadWidgets(); +Bangle.drawWidgets(); + +// Launch an app given the current ID. Handles special cases: +// false: Do nothing +// '#LAUNCHER': Open the launcher +// nonexistent app: Do nothing +function launch(appId) { + if (appId == false) return; + else if (appId == '#LAUNCHER') { + Bangle.buzz(); + Bangle.showLauncher(); + } else { + let appInfo = storage.readJSON(appId + '.info', 1); + if (appInfo) { + Bangle.buzz(); + load(appInfo.src); + } + } +} + +//Set up touch to launch the selected app +Bangle.on('touch', function (button, xy) { + let x = Math.floor(xy.x / 44); + if (x < 0) x = 0; + else if (x > 3) x = 3; + + let y = Math.floor(xy.y / 44); + if (y < 0) y = -1; + else if (y > 3) y = 1; + else y -= 2; + + if (y < 0) { + Bangle.buzz(); + Bangle.showLauncher(); + } else { + let i = 4 * y + x; + launch(config.shortcuts[i]); + } +}); + +//Set up swipe handler +Bangle.on('swipe', function (direction) { + if (direction == -1) launch(config.swipe.left); + else if (direction == 0) launch(config.swipe.up); + else launch(config.swipe.right); +}); + +if (!Bangle.isLocked()) drawIcons(); + +draw(); \ No newline at end of file diff --git a/apps/infoclk/font.js b/apps/infoclk/font.js new file mode 100644 index 000000000..6063958e7 --- /dev/null +++ b/apps/infoclk/font.js @@ -0,0 +1,23 @@ +const heatshrink = require("heatshrink") + +function decompress(string) { + return heatshrink.decompress(atob(string)) +} + +exports = { + '0': decompress("ktAwIEB////EAj4EB+EDAYP/8E/AgWDAYX+CIX/+IDC//PBoYIDAAvwgEHAgOAgAnB/kAgIvCgEPAgJCBv5CCHwXAI4X+KAYk/En4kmAA4qBAAP7BAePAYX4BofBAYX8F4Q+BEwRHBIQI5BA"), + '1': decompress("ktAwIGDj/4AgX/4ADBg/+BAU/+ADBgP/wAEBh/8BoV/8ADBgf/En4k/En4k/EgQ="), + '2': decompress("ktAwMA/4AB/EHAgXwn4EC8IDC/+PAYX+v4EC+YND74NDBAYAE4A0Bg/+HIU/+ADBgP/wAEBh/8BoV/8ADBgf/BAUf/AECElQdBPA2HAYX8OYfHBAYRD8Z3Dj6TG/kPPYZm4EiwAHO4f7BAfPfI/xBoaTEPAfgQwY"), + '3': decompress("ktAwMA/4AB/EHAgXwn4EC8IDC/+PAYX+v4EC+YND74NDBAYAE4A0Bg/+HIU/+ADBgP/wAEBh/8BoV/8ADBgf/BAUf/AECElJWIAEpu/EhpgS34DC/IID54DC/l/AgXDAYX4j57DA"), + '4': decompress("ktAwMA//AgEf//+BYP///wgEHAgOAgE///8gEBBAPggEPAgIWBv///EAgYIBEn4kXABf9AgfnAgY4BAAP4BAfDAYX+EwfwIQRRCJIJRBJIRRBJIQICj5RBJIRRBJIJRCNwJRBNwQk/Ei4A=="), + '5': decompress("ktAwIEB/4AB/EfAgXDAYX+n4EC+YDC/+fAYX9BAfvAgYAJ+AwBgP/wAEBh/8H4V/8ADBgf/BAUf/AEC//AAYMH/wICn4kpPYUPAgXgv4EC4JfDg4DC/iFD8ANDwaTDCQfwEoZ2/EhrXNAAm/AYX5BAfPQoaTD4ahDj57DA=="), + '6': decompress("ktAwIEB/4AB/EfAgXDAYX+n4EC+YDC/+fAYX9BAfvAgYAJ+AwBgP/wAEBh/8H4V/8ADBgf/BAUf/AEC//AAYMH/wICn4kpPYUPAgXgv6AG/6JD/gID84ED358NJIIsCKIQ0BKIRJCFgJJCSYcHAgJuBXYJuBKIQkpAA58D/YIDx6PDBofBQoYvCHwImCI4KUCwA="), + '7': decompress("ktAwMA/4AB/EHAgXwn4EC8IDC/+PAYX+v4EC+YND74NDBAYAE4A0Bg/+HIU/+ADBgP/wAEBh/8BoV/8ADBgf/BAUf/AECEn4k/En4kVA"), + '8': decompress("ktAwIEB////EAj4EB+EDAYP/8E/AgWDAYX+CIX/+IDC//PBoYIDAAvwgEHAgOAgAnB/kAgIvCgEPAgJCBv5CCHwXAI4X+KAYkpAFpu/EhwAHFQIAB/YIDx4DC/AND4IDC/ieD4AmCI4JCBHIIA=="), + '9': decompress("ktAwIEB////EAj4EB+EDAYP/8E/AgWDAYX+CIX/+IDC//PBoYIDAAvwgEHAgOAgAnB/kAgIvCgEPAgJCBv5CCHwXAI4X+KAYkpABf9AgfnAgaFD/AID4Z8DEwfwIQRRCJIJRBJIRRBJIQICj5RBJIRRBJIJRCNwJRBNwQkoPhoAE34DC/L0H/iwBQAv4WAJ7CA=="), + + ':': decompress("iFAwITQg/gj/4n/8v/+AIP/ABQPDCoIZBDoJTfH94A=="), + + 'am': decompress("jFAwIEBngCEvwCH/4CFwEBAQkD//AgfnAQcH4fgAQsPwPwAQf/+Ef//4AQn8n0AvgCCHQN+vkAnwCC/EAj4CF+EAh4CCNIoLFC4v8gE/AQv+gF/AQpwB/4CDwICG+/D94CD8/v+fn54CC+P/x4CF+H/IgICFvwCEngCD"), + 'pm': decompress("jFAwMAn///l///+/4AE+EAh4CaEYoABFgX8BwMAAUwAFIIv4gEfAQX8OYICF/0Av4CF/8AKQICCwICG+/D94CD8/v+fn54CC+P/x4CF+H/IgICFvwCEngCDA") +} \ No newline at end of file diff --git a/apps/infoclk/font/am.png b/apps/infoclk/font/am.png new file mode 100644 index 000000000..a76ad25fc Binary files /dev/null and b/apps/infoclk/font/am.png differ diff --git a/apps/infoclk/font/colon.png b/apps/infoclk/font/colon.png new file mode 100644 index 000000000..878770dde Binary files /dev/null and b/apps/infoclk/font/colon.png differ diff --git a/apps/infoclk/font/digit0.png b/apps/infoclk/font/digit0.png new file mode 100644 index 000000000..5470154ee Binary files /dev/null and b/apps/infoclk/font/digit0.png differ diff --git a/apps/infoclk/font/digit1.png b/apps/infoclk/font/digit1.png new file mode 100644 index 000000000..26a35fd1b Binary files /dev/null and b/apps/infoclk/font/digit1.png differ diff --git a/apps/infoclk/font/digit2.png b/apps/infoclk/font/digit2.png new file mode 100644 index 000000000..92974daf3 Binary files /dev/null and b/apps/infoclk/font/digit2.png differ diff --git a/apps/infoclk/font/digit3.png b/apps/infoclk/font/digit3.png new file mode 100644 index 000000000..6751067c6 Binary files /dev/null and b/apps/infoclk/font/digit3.png differ diff --git a/apps/infoclk/font/digit4.png b/apps/infoclk/font/digit4.png new file mode 100644 index 000000000..fdb0c5f8d Binary files /dev/null and b/apps/infoclk/font/digit4.png differ diff --git a/apps/infoclk/font/digit5.png b/apps/infoclk/font/digit5.png new file mode 100644 index 000000000..5647ad00a Binary files /dev/null and b/apps/infoclk/font/digit5.png differ diff --git a/apps/infoclk/font/digit6.png b/apps/infoclk/font/digit6.png new file mode 100644 index 000000000..56c446881 Binary files /dev/null and b/apps/infoclk/font/digit6.png differ diff --git a/apps/infoclk/font/digit7.png b/apps/infoclk/font/digit7.png new file mode 100644 index 000000000..1fb6a6423 Binary files /dev/null and b/apps/infoclk/font/digit7.png differ diff --git a/apps/infoclk/font/digit8.png b/apps/infoclk/font/digit8.png new file mode 100644 index 000000000..a373205f1 Binary files /dev/null and b/apps/infoclk/font/digit8.png differ diff --git a/apps/infoclk/font/digit9.png b/apps/infoclk/font/digit9.png new file mode 100644 index 000000000..990a3a43b Binary files /dev/null and b/apps/infoclk/font/digit9.png differ diff --git a/apps/infoclk/font/pm.png b/apps/infoclk/font/pm.png new file mode 100644 index 000000000..a3db97eb8 Binary files /dev/null and b/apps/infoclk/font/pm.png differ diff --git a/apps/infoclk/icon.js b/apps/infoclk/icon.js new file mode 100644 index 000000000..ae230d8f4 --- /dev/null +++ b/apps/infoclk/icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwgOAA4YFS/4AKEf5BlABcAjAgBjAfBAuhH/Apo")) \ No newline at end of file diff --git a/apps/infoclk/icon.png b/apps/infoclk/icon.png new file mode 100644 index 000000000..24423fbd6 Binary files /dev/null and b/apps/infoclk/icon.png differ diff --git a/apps/infoclk/metadata.json b/apps/infoclk/metadata.json new file mode 100644 index 000000000..bb6dea3a4 --- /dev/null +++ b/apps/infoclk/metadata.json @@ -0,0 +1,38 @@ +{ + "id": "infoclk", + "name": "Informational clock", + "version": "0.08", + "description": "A configurable clock with extra info and shortcuts when unlocked, but large time when locked", + "readme": "README.md", + "icon": "icon.png", + "type": "clock", + "tags": "clock", + "supports": [ + "BANGLEJS2" + ], + "allow_emulator": true, + "storage": [ + { + "name": "infoclk.app.js", + "url": "app.js" + }, + { + "name": "infoclk.settings.js", + "url": "settings.js" + }, + { + "name": "infoclk-font.js", + "url": "font.js" + }, + { + "name": "infoclk.img", + "url": "icon.js", + "evaluate": true + } + ], + "data": [ + { + "name": "infoclk.json" + } + ] +} \ No newline at end of file diff --git a/apps/infoclk/settings.js b/apps/infoclk/settings.js new file mode 100644 index 000000000..0bc3d4b15 --- /dev/null +++ b/apps/infoclk/settings.js @@ -0,0 +1,571 @@ +(function (back) { + const SETTINGS_FILE = "infoclk.json"; + const storage = require('Storage'); + + let config = Object.assign({ + seconds: { + // Displaying the seconds can reduce battery life because the CPU must wake up more often to update the display. + // The seconds will be shown unless one of these conditions is enabled here, and currently true. + hideLocked: false, // Hide the seconds when the display is locked. + hideBattery: 20, // Hide the seconds when the battery is at or below a defined percentage. + hideTime: true, // Hide the seconds when between a certain period of time. Useful for when you are sleeping and don't need the seconds + hideStart: 2200, // The time when the seconds are hidden: first 2 digits are hours on a 24 hour clock, last 2 are minutes + hideEnd: 700, // The time when the seconds are shown again + hideAlways: false, // Always hide (never show) the seconds + }, + + date: { + // Settings related to the display of the date + mmdd: true, // If true, display the month first. If false, display the date first. + separator: '-', // The character that goes between the month and date + monthName: false, // If false, display the month as a number. If true, display the name. + monthFullName: false, // If displaying the name: If false, display an abbreviation. If true, display a full name. + dayFullName: false, // If false, display the day of the week's abbreviation. If true, display the full name. + }, + + bottomLocked: { + display: 'weather' // What to display in the bottom row when locked: + // 'weather': The current temperature and weather description + // 'steps': Step count + // 'health': Step count and bpm + // 'progress': Day progress bar + // false: Nothing + }, + + shortcuts: [ + //8 shortcuts, displayed in the bottom half of the screen (2 rows of 4 shortcuts) when unlocked + // false = no shortcut + // '#LAUNCHER' = open the launcher + // any other string = name of app to open + 'stlap', 'keytimer', 'pomoplus', 'alarm', + 'rpnsci', 'calendar', 'torch', 'weather' + ], + + swipe: { + // 3 shortcuts to launch upon swiping: + // false = no shortcut + // '#LAUNCHER' = open the launcher + // any other string = name of app to open + up: 'messages', // Swipe up or swipe down, due to limitation of event handler + left: '#LAUNCHER', + right: '#LAUNCHER', + }, + + dayProgress: { + // A progress bar representing how far through the day you are + enabledLocked: true, // Whether this bar is enabled when the watch is locked + enabledUnlocked: false, // Whether the bar is enabled when the watch is unlocked + color: [0, 0, 1], // The color of the bar + start: 700, // The time of day that the bar starts filling + end: 2200, // The time of day that the bar becomes full + reset: 300 // The time of day when the progress bar resets from full to empty + }, + + lowBattColor: { + // The text can change color to indicate that the battery is low + level: 20, // The percentage where this happens + color: [1, 0, 0] // The color that the text changes to + } + }, storage.readJSON(SETTINGS_FILE)); + + function saveSettings() { + storage.writeJSON(SETTINGS_FILE, config); + } + + function hourToString(hour) { + if (storage.readJSON('setting.json')['12hour']) { + if (hour == 0) return '12 AM'; + else if (hour < 12) return `${hour} AM`; + else if (hour == 12) return '12 PM'; + else return `${hour - 12} PM`; + } else return '' + hour; + } + + // The menu for configuring when the seconds are shown + function showSecondsMenu() { + E.showMenu({ + '': { + 'title': 'Seconds display', + 'back': showMainMenu + }, + 'Show seconds': { + value: !config.seconds.hideAlways, + onchange: value => { + config.seconds.hideAlways = !value; + saveSettings(); + } + }, + '...unless locked': { + value: config.seconds.hideLocked, + onchange: value => { + config.seconds.hideLocked = value; + saveSettings(); + } + }, + '...unless battery below': { + value: config.seconds.hideBattery, + min: 0, + max: 100, + format: value => `${value}%`, + onchange: value => { + config.seconds.hideBattery = value; + saveSettings(); + } + }, + '...unless between these 2 times...': () => { + E.showMenu({ + '': { + 'title': 'Hide seconds between', + 'back': showSecondsMenu + }, + 'Enabled': { + value: config.seconds.hideTime, + onchange: value => { + config.seconds.hideTime = value; + saveSettings(); + } + }, + 'Start hour': { + value: Math.floor(config.seconds.hideStart / 100), + format: hourToString, + min: 0, + max: 23, + wrap: true, + onchange: hour => { + minute = config.seconds.hideStart % 100; + config.seconds.hideStart = (100 * hour) + minute; + saveSettings(); + } + }, + 'Start minute': { + value: config.seconds.hideStart % 100, + min: 0, + max: 59, + wrap: true, + onchange: minute => { + hour = Math.floor(config.seconds.hideStart / 100); + config.seconds.hideStart = (100 * hour) + minute; + saveSettings(); + } + }, + 'End hour': { + value: Math.floor(config.seconds.hideEnd / 100), + format: hourToString, + min: 0, + max: 23, + wrap: true, + onchange: hour => { + minute = config.seconds.hideEnd % 100; + config.seconds.hideEnd = (100 * hour) + minute; + saveSettings(); + } + }, + 'End minute': { + value: config.seconds.hideEnd % 100, + min: 0, + max: 59, + wrap: true, + onchange: minute => { + hour = Math.floor(config.seconds.hideEnd / 100); + config.seconds.hideEnd = (100 * hour) + minute; + saveSettings(); + } + } + }); + } + }); + } + + // Available month/date separators + const SEPARATORS = [ + { name: 'Slash', char: '/' }, + { name: 'Dash', char: '-' }, + { name: 'Space', char: ' ' }, + { name: 'Comma', char: ',' }, + { name: 'None', char: '' } + ]; + + // Available bottom row display options + const BOTTOM_ROW_OPTIONS = [ + { name: 'Weather', val: 'weather' }, + { name: 'Step count', val: 'steps' }, + { name: 'Steps + BPM', val: 'health' }, + { name: 'Day progresss bar', val: 'progress' }, + { name: 'Nothing', val: false } + ]; + + // The menu for configuring which apps have shortcut icons + function showShortcutMenu() { + //Builds the shortcut options + let shortcutOptions = [ + { name: 'Nothing', val: false }, + { name: 'Launcher', val: '#LAUNCHER' }, + ]; + + let infoFiles = storage.list(/\.info$/).sort((a, b) => { + if (a.name < b.name) return -1; + else if (a.name > b.name) return 1; + else return 0; + }); + for (let infoFile of infoFiles) { + let appInfo = storage.readJSON(infoFile); + if (appInfo.src) shortcutOptions.push({ + name: appInfo.name, + val: appInfo.id + }); + } + + E.showMenu({ + '': { + 'title': 'Shortcuts', + 'back': showMainMenu + }, + 'Top first': { + value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[0]), + format: value => shortcutOptions[value].name, + min: 0, + max: shortcutOptions.length - 1, + wrap: false, + onchange: value => { + config.shortcuts[0] = shortcutOptions[value].val; + saveSettings(); + } + }, + 'Top second': { + value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[1]), + format: value => shortcutOptions[value].name, + min: 0, + max: shortcutOptions.length - 1, + wrap: false, + onchange: value => { + config.shortcuts[1] = shortcutOptions[value].val; + saveSettings(); + } + }, + 'Top third': { + value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[2]), + format: value => shortcutOptions[value].name, + min: 0, + max: shortcutOptions.length - 1, + wrap: false, + onchange: value => { + config.shortcuts[2] = shortcutOptions[value].val; + saveSettings(); + } + }, + 'Top fourth': { + value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[3]), + format: value => shortcutOptions[value].name, + min: 0, + max: shortcutOptions.length - 1, + wrap: false, + onchange: value => { + config.shortcuts[3] = shortcutOptions[value].val; + saveSettings(); + } + }, + 'Bottom first': { + value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[4]), + format: value => shortcutOptions[value].name, + min: 0, + max: shortcutOptions.length - 1, + wrap: false, + onchange: value => { + config.shortcuts[4] = shortcutOptions[value].val; + saveSettings(); + } + }, + 'Bottom second': { + value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[5]), + format: value => shortcutOptions[value].name, + min: 0, + max: shortcutOptions.length - 1, + wrap: false, + onchange: value => { + config.shortcuts[5] = shortcutOptions[value].val; + saveSettings(); + } + }, + 'Bottom third': { + value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[6]), + format: value => shortcutOptions[value].name, + min: 0, + max: shortcutOptions.length - 1, + wrap: false, + onchange: value => { + config.shortcuts[6] = shortcutOptions[value].val; + saveSettings(); + } + }, + 'Bottom fourth': { + value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[7]), + format: value => shortcutOptions[value].name, + min: 0, + max: shortcutOptions.length - 1, + wrap: false, + onchange: value => { + config.shortcuts[7] = shortcutOptions[value].val; + saveSettings(); + } + }, + 'Swipe up': { + value: shortcutOptions.map(item => item.val).indexOf(config.swipe.up), + format: value => shortcutOptions[value].name, + min: 0, + max: shortcutOptions.length - 1, + wrap: false, + onchange: value => { + config.swipe.up = shortcutOptions[value].val; + saveSettings(); + } + }, + 'Swipe left': { + value: shortcutOptions.map(item => item.val).indexOf(config.swipe.left), + format: value => shortcutOptions[value].name, + min: 0, + max: shortcutOptions.length - 1, + wrap: false, + onchange: value => { + config.swipe.left = shortcutOptions[value].val; + saveSettings(); + } + }, + 'Swipe right': { + value: shortcutOptions.map(item => item.val).indexOf(config.swipe.right), + format: value => shortcutOptions[value].name, + min: 0, + max: shortcutOptions.length - 1, + wrap: false, + onchange: value => { + config.swipe.right = shortcutOptions[value].val; + saveSettings(); + } + }, + }); + } + + const COLOR_OPTIONS = [ + { name: 'Black', val: [0, 0, 0] }, + { name: 'Blue', val: [0, 0, 1] }, + { name: 'Green', val: [0, 1, 0] }, + { name: 'Cyan', val: [0, 1, 1] }, + { name: 'Red', val: [1, 0, 0] }, + { name: 'Magenta', val: [1, 0, 1] }, + { name: 'Yellow', val: [1, 1, 0] }, + { name: 'White', val: [1, 1, 1] } + ]; + + // Workaround for being unable to use == on arrays: convert them into strings + function colorString(color) { + return `${color[0]} ${color[1]} ${color[2]}`; + } + + //Shows the top level menu + function showMainMenu() { + E.showMenu({ + '': { + 'title': 'Informational Clock', + 'back': back + }, + 'Seconds display': showSecondsMenu, + 'Day of week format': { + value: config.date.dayFullName, + format: value => value ? 'Full name' : 'Abbreviation', + onchange: value => { + config.date.dayFullName = value; + saveSettings(); + } + }, + 'Date format': () => { + E.showMenu({ + '': { + 'title': 'Date format', + 'back': showMainMenu, + }, + 'Order': { + value: config.date.mmdd, + format: value => value ? 'Month first' : 'Date first', + onchange: value => { + config.date.mmdd = value; + saveSettings(); + } + }, + 'Separator': { + value: SEPARATORS.map(item => item.char).indexOf(config.date.separator), + format: value => SEPARATORS[value].name, + min: 0, + max: SEPARATORS.length - 1, + wrap: true, + onchange: value => { + config.date.separator = SEPARATORS[value].char; + saveSettings(); + } + }, + 'Month format': { + // 0 = number only + // 1 = abbreviation + // 2 = full name + value: config.date.monthName ? (config.date.monthFullName ? 2 : 1) : 0, + format: value => ['Number', 'Abbreviation', 'Full name'][value], + min: 0, + max: 2, + wrap: true, + onchange: value => { + if (value == 0) config.date.monthName = false; + else { + config.date.monthName = true; + config.date.monthFullName = (value == 2); + } + saveSettings(); + } + } + }); + }, + 'Bottom row': { + value: BOTTOM_ROW_OPTIONS.map(item => item.val).indexOf(config.bottomLocked.display), + format: value => BOTTOM_ROW_OPTIONS[value].name, + min: 0, + max: BOTTOM_ROW_OPTIONS.length - 1, + wrap: true, + onchange: value => { + config.bottomLocked.display = BOTTOM_ROW_OPTIONS[value].val; + saveSettings(); + } + }, + 'Shortcuts': showShortcutMenu, + 'Day progress': () => { + E.showMenu({ + '': { + 'title': 'Day progress', + 'back': showMainMenu + }, + 'Enable while locked': { + value: config.dayProgress.enabledLocked, + onchange: value => { + config.dayProgress.enableLocked = value; + saveSettings(); + } + }, + 'Enable while unlocked': { + value: config.dayProgress.enabledUnlocked, + onchange: value => { + config.dayProgress.enabledUnlocked = value; + saveSettings(); + } + }, + 'Color': { + value: COLOR_OPTIONS.map(item => colorString(item.val)).indexOf(colorString(config.dayProgress.color)), + format: value => COLOR_OPTIONS[value].name, + min: 0, + max: COLOR_OPTIONS.length - 1, + wrap: false, + onchange: value => { + config.dayProgress.color = COLOR_OPTIONS[value].val; + saveSettings(); + } + }, + 'Start hour': { + value: Math.floor(config.dayProgress.start / 100), + format: hourToString, + min: 0, + max: 23, + wrap: true, + onchange: hour => { + minute = config.dayProgress.start % 100; + config.dayProgress.start = (100 * hour) + minute; + saveSettings(); + } + }, + 'Start minute': { + value: config.dayProgress.start % 100, + min: 0, + max: 59, + wrap: true, + onchange: minute => { + hour = Math.floor(config.dayProgress.start / 100); + config.dayProgress.start = (100 * hour) + minute; + saveSettings(); + } + }, + 'End hour': { + value: Math.floor(config.dayProgress.end / 100), + format: hourToString, + min: 0, + max: 23, + wrap: true, + onchange: hour => { + minute = config.dayProgress.end % 100; + config.dayProgress.end = (100 * hour) + minute; + saveSettings(); + } + }, + 'End minute': { + value: config.dayProgress.end % 100, + min: 0, + max: 59, + wrap: true, + onchange: minute => { + hour = Math.floor(config.dayProgress.end / 100); + config.dayProgress.end = (100 * hour) + minute; + saveSettings(); + } + }, + 'Reset hour': { + value: Math.floor(config.dayProgress.reset / 100), + format: hourToString, + min: 0, + max: 23, + wrap: true, + onchange: hour => { + minute = config.dayProgress.reset % 100; + config.dayProgress.reset = (100 * hour) + minute; + saveSettings(); + } + }, + 'Reset minute': { + value: config.dayProgress.reset % 100, + min: 0, + max: 59, + wrap: true, + onchange: minute => { + hour = Math.floor(config.dayProgress.reset / 100); + config.dayProgress.reset = (100 * hour) + minute; + saveSettings(); + } + } + }); + }, + 'Low battery color': () => { + E.showMenu({ + '': { + 'title': 'Low battery color', + back: showMainMenu + }, + 'Low battery threshold': { + value: config.lowBattColor.level, + min: 0, + max: 100, + format: value => `${value}%`, + onchange: value => { + config.lowBattColor.level = value; + saveSettings(); + } + }, + 'Color': { + value: COLOR_OPTIONS.map(item => colorString(item.val)).indexOf(colorString(config.lowBattColor.color)), + format: value => COLOR_OPTIONS[value].name, + min: 0, + max: COLOR_OPTIONS.length - 1, + wrap: false, + onchange: value => { + config.lowBattColor.color = COLOR_OPTIONS[value].val; + saveSettings(); + } + } + }); + }, + }); + } + + showMainMenu(); +}); \ No newline at end of file diff --git a/apps/kbswipe/ChangeLog b/apps/kbswipe/ChangeLog index f0dc54b69..1804c4a89 100644 --- a/apps/kbswipe/ChangeLog +++ b/apps/kbswipe/ChangeLog @@ -2,3 +2,4 @@ 0.02: Now keeps user input trace intact by changing how the screen is updated. 0.03: Positioning of marker now takes the height of the widget field into account. 0.04: Fix issue if going back without typing. +0.05: Keep drag-function in ram, hopefully improving performance and input reliability somewhat. diff --git a/apps/kbswipe/lib.js b/apps/kbswipe/lib.js index 417ac98d9..fe5f7e977 100644 --- a/apps/kbswipe/lib.js +++ b/apps/kbswipe/lib.js @@ -139,6 +139,7 @@ exports.getStrokes( (id,s) => Bangle.strokes[id] = Unistroke.new(s) ); return new Promise((resolve,reject) => { var l;//last event Bangle.setUI({mode:"custom", drag:e=>{ + "ram"; if (l) g.reset().setColor("#f00").drawLine(l.x,l.y,e.x,e.y); l = e.b ? e : 0; },touch:() => { diff --git a/apps/kbswipe/metadata.json b/apps/kbswipe/metadata.json index d4026c815..59622cb96 100644 --- a/apps/kbswipe/metadata.json +++ b/apps/kbswipe/metadata.json @@ -1,6 +1,6 @@ { "id": "kbswipe", "name": "Swipe keyboard", - "version":"0.04", + "version":"0.05", "description": "A library for text input via PalmOS style swipe gestures (beta!)", "icon": "app.png", "type":"textinput", diff --git a/apps/keytimer/ChangeLog b/apps/keytimer/ChangeLog new file mode 100644 index 000000000..c819919ed --- /dev/null +++ b/apps/keytimer/ChangeLog @@ -0,0 +1,2 @@ +0.01: New app! +0.02: Submitted to the app loader \ No newline at end of file diff --git a/apps/keytimer/app.js b/apps/keytimer/app.js new file mode 100644 index 000000000..7d235f9a8 --- /dev/null +++ b/apps/keytimer/app.js @@ -0,0 +1,27 @@ +Bangle.keytimer_ACTIVE = true; +const common = require("keytimer-com.js"); +const storage = require("Storage"); + +const keypad = require("keytimer-keys.js"); +const timerView = require("keytimer-tview.js"); + +Bangle.loadWidgets(); +Bangle.drawWidgets(); + +//Save our state when the app is closed +E.on('kill', () => { + storage.writeJSON(common.STATE_PATH, common.state); +}); + +//Handle touch here. I would implement these separately in each view, but I can't figure out how to clear the event listeners. +Bangle.on('touch', (button, xy) => { + if (common.state.wasRunning) timerView.touch(button, xy); + else keypad.touch(button, xy); +}); + +Bangle.on('swipe', dir => { + if (!common.state.wasRunning) keypad.swipe(dir); +}); + +if (common.state.wasRunning) timerView.show(common); +else keypad.show(common); diff --git a/apps/keytimer/boot.js b/apps/keytimer/boot.js new file mode 100644 index 000000000..f202bcbdf --- /dev/null +++ b/apps/keytimer/boot.js @@ -0,0 +1,11 @@ +const keytimer_common = require("keytimer-com.js"); + +//Only start the timeout if the timer is running +if (keytimer_common.state.running) { + setTimeout(() => { + //Check now to avoid race condition + if (Bangle.keytimer_ACTIVE === undefined) { + load('keytimer-ring.js'); + } + }, keytimer_common.getTimeLeft()); +} \ No newline at end of file diff --git a/apps/keytimer/common.js b/apps/keytimer/common.js new file mode 100644 index 000000000..8c702de66 --- /dev/null +++ b/apps/keytimer/common.js @@ -0,0 +1,42 @@ +const storage = require("Storage"); +const heatshrink = require("heatshrink"); + +exports.STATE_PATH = "keytimer.state.json"; + +exports.BUTTON_ICONS = { + play: heatshrink.decompress(atob("jEYwMAkAGBnACBnwCBn+AAQPgAQPwAQP8AQP/AQXAAQPwAQP8AQP+AQgICBwQUCEAn4FggyBHAQ+CIgQ")), + pause: heatshrink.decompress(atob("jEYwMA/4BBAX4CEA")), + reset: heatshrink.decompress(atob("jEYwMA/4BB/+BAQPDAQPnAQIAKv///0///8j///EP//wAQQICBwQUCEhgyCHAQ+CIgI=")) +}; + +//Store the minimal amount of information to be able to reconstruct the state of the timer at any given time. +//This is necessary because it is necessary to write to flash to let the timer run in the background, so minimizing the writes is necessary. +exports.STATE_DEFAULT = { + wasRunning: false, //If the timer ever was running. Used to determine whether to display a reset button + running: false, //Whether the timer is currently running + startTime: 0, //When the timer was last started. Difference between this and now is how long timer has run continuously. + pausedTime: 0, //When the timer was last paused. Used for expiration and displaying timer while paused. + elapsedTime: 0, //How much time the timer had spent running before the current start time. Update on pause or user skipping stages. + setTime: 0, //How long the user wants the timer to run for + inputString: '0' //The string of numbers the user typed in. +}; +exports.state = storage.readJSON(exports.STATE_PATH); +if (!exports.state) { + exports.state = exports.STATE_DEFAULT; +} + +//Get the number of milliseconds until the timer expires +exports.getTimeLeft = function () { + if (!exports.state.wasRunning) { + //If the timer never ran, the time left is just the set time + return exports.setTime + } else if (exports.state.running) { + //If the timer is running, the time left is current time - start time + preexisting time + var runningTime = (new Date()).getTime() - exports.state.startTime + exports.state.elapsedTime; + } else { + //If the timer is not running, the same as above but use when the timer was paused instead of now. + var runningTime = exports.state.pausedTime - exports.state.startTime + exports.state.elapsedTime; + } + + return exports.state.setTime - runningTime; +} \ No newline at end of file diff --git a/apps/keytimer/icon.js b/apps/keytimer/icon.js new file mode 100644 index 000000000..a47eb21f8 --- /dev/null +++ b/apps/keytimer/icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwcAkmSpICOggRPpEACJ9AgESCJxMBhu27dtARVgCIMBCJpxDmwRL7ARDgwRL4CWECJaoFjYRJ2ARFgYRJwDNGCJFsb46SIRgQAFSRAQHSRCMEAAqSGRgoAFRhaSKRgySKRg6SIRhCSIRhCSICBqSCRhSSGRhY2FkARPhMkCJ9JkiONgECCIOQCJsSCIOSCJuSCIVACBcECIdICJYOBCIVJRhYRFSRSMBCIiSKBwgCCSRCMCCIqSIRgYCFRhYCFSQyMEAQqSGBw6SIRgySKRgtO4iSJBAmT23bOIqSCRgvtCINsSQ4aEndtCINt2KSGIggOBCIW2JQlARgZECCIhKEpBEGCIpKEA==")) \ No newline at end of file diff --git a/apps/keytimer/icon.png b/apps/keytimer/icon.png new file mode 100644 index 000000000..7dcf44b88 Binary files /dev/null and b/apps/keytimer/icon.png differ diff --git a/apps/keytimer/img/pause.png b/apps/keytimer/img/pause.png new file mode 100644 index 000000000..ad31dadcf Binary files /dev/null and b/apps/keytimer/img/pause.png differ diff --git a/apps/keytimer/img/play.png b/apps/keytimer/img/play.png new file mode 100644 index 000000000..6c20c24c5 Binary files /dev/null and b/apps/keytimer/img/play.png differ diff --git a/apps/keytimer/img/reset.png b/apps/keytimer/img/reset.png new file mode 100644 index 000000000..7a317d097 Binary files /dev/null and b/apps/keytimer/img/reset.png differ diff --git a/apps/keytimer/keypad.js b/apps/keytimer/keypad.js new file mode 100644 index 000000000..a5edeb2f2 --- /dev/null +++ b/apps/keytimer/keypad.js @@ -0,0 +1,136 @@ +let common; + +function inputStringToTime(inputString) { + let number = parseInt(inputString); + let hours = Math.floor(number / 10000); + let minutes = Math.floor((number % 10000) / 100); + let seconds = number % 100; + + return 3600000 * hours + + 60000 * minutes + + 1000 * seconds; +} + +function pad(number) { + return ('00' + parseInt(number)).slice(-2); +} + +function inputStringToDisplayString(inputString) { + let number = parseInt(inputString); + let hours = Math.floor(number / 10000); + let minutes = Math.floor((number % 10000) / 100); + let seconds = number % 100; + + if (hours == 0 && minutes == 0) return '' + seconds; + else if (hours == 0) return `${pad(minutes)}:${pad(seconds)}`; + else return `${hours}:${pad(minutes)}:${pad(seconds)}`; +} + +class NumberButton { + constructor(number) { + this.label = '' + number; + } + + onclick() { + if (common.state.inputString == '0') common.state.inputString = this.label; + else common.state.inputString += this.label; + common.state.setTime = inputStringToTime(common.state.inputString); + feedback(true); + updateDisplay(); + } +} + +let ClearButton = { + label: 'Clr', + onclick: () => { + common.state.inputString = '0'; + common.state.setTime = 0; + updateDisplay(); + feedback(true); + } +}; + +let StartButton = { + label: 'Go', + onclick: () => { + common.state.startTime = (new Date()).getTime(); + common.state.elapsedTime = 0; + common.state.wasRunning = true; + common.state.running = true; + feedback(true); + require('keytimer-tview.js').show(common); + } +}; + +const BUTTONS = [ + [new NumberButton(7), new NumberButton(8), new NumberButton(9), ClearButton], + [new NumberButton(4), new NumberButton(5), new NumberButton(6), new NumberButton(0)], + [new NumberButton(1), new NumberButton(2), new NumberButton(3), StartButton] +]; + +function feedback(acceptable) { + if (acceptable) Bangle.buzz(50, 0.5); + else Bangle.buzz(200, 1); +} + +function drawButtons() { + g.reset().clearRect(0, 44, 175, 175).setFont("Vector", 15).setFontAlign(0, 0); + //Draw lines + for (let x = 44; x <= 176; x += 44) { + g.drawLine(x, 44, x, 175); + } + for (let y = 44; y <= 176; y += 44) { + g.drawLine(0, y, 175, y); + } + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 4; col++) { + g.drawString(BUTTONS[row][col].label, 22 + 44 * col, 66 + 44 * row); + } + } +} + +function getFontSize(length) { + let size = Math.floor(176 / length); //Characters of width needed per pixel + size *= (20 / 12); //Convert to height + // Clamp to between 6 and 20 + if (size < 6) return 6; + else if (size > 20) return 20; + else return Math.floor(size); +} + +function updateDisplay() { + let displayString = inputStringToDisplayString(common.state.inputString); + g.clearRect(0, 24, 175, 43).setColor(storage.readJSON('setting.json').theme.fg2).setFontAlign(1, -1).setFont("Vector", getFontSize(displayString.length)).drawString(displayString, 176, 24); +} + +exports.show = function (callerCommon) { + common = callerCommon; + g.reset(); + drawButtons(); + updateDisplay(); +}; + +exports.touch = function (button, xy) { + let row = Math.floor((xy.y - 44) / 44); + let col = Math.floor(xy.x / 44); + if (row < 0) return; + if (row > 2) row = 2; + if (col < 0) col = 0; + if (col > 3) col = 3; + + BUTTONS[row][col].onclick(); +}; + +exports.swipe = function (dir) { + if (dir == -1) { + if (common.state.inputString.length == 1) common.state.inputString = '0'; + else common.state.inputString = common.state.inputString.substring(0, common.state.inputString.length - 1); + + common.state.setTime = inputStringToTime(common.state.inputString); + + feedback(true); + updateDisplay(); + } else if (dir == 0) { + EnterButton.onclick(); + } +}; \ No newline at end of file diff --git a/apps/keytimer/metadata.json b/apps/keytimer/metadata.json new file mode 100644 index 000000000..a982594f1 --- /dev/null +++ b/apps/keytimer/metadata.json @@ -0,0 +1,44 @@ +{ + "id": "keytimer", + "name": "Keypad Timer", + "version": "0.02", + "description": "A timer with a keypad that runs in the background", + "icon": "icon.png", + "type": "app", + "tags": "tools", + "supports": [ + "BANGLEJS2" + ], + "allow_emulator": true, + "storage": [ + { + "name": "keytimer.app.js", + "url": "app.js" + }, + { + "name": "keytimer.img", + "url": "icon.js", + "evaluate": true + }, + { + "name": "keytimer.boot.js", + "url": "boot.js" + }, + { + "name": "keytimer-com.js", + "url": "common.js" + }, + { + "name": "keytimer-ring.js", + "url": "ring.js" + }, + { + "name": "keytimer-keys.js", + "url": "keypad.js" + }, + { + "name": "keytimer-tview.js", + "url": "timerview.js" + } + ] +} \ No newline at end of file diff --git a/apps/keytimer/ring.js b/apps/keytimer/ring.js new file mode 100644 index 000000000..c42c11394 --- /dev/null +++ b/apps/keytimer/ring.js @@ -0,0 +1,28 @@ +const common = require('keytimer-com.js'); + +Bangle.loadWidgets() +Bangle.drawWidgets() + +Bangle.setLocked(false); +Bangle.setLCDPower(true); + +let brightness = 0; + +setInterval(() => { + Bangle.buzz(200); + Bangle.setLCDBrightness(1 - brightness); + brightness = 1 - brightness; +}, 400); +Bangle.buzz(200); + +function stopTimer() { + common.state.wasRunning = false; + common.state.running = false; + require("Storage").writeJSON(common.STATE_PATH, common.state); +} + +E.showAlert("Timer expired!").then(() => { + stopTimer(); + load(); +}); +E.on('kill', stopTimer); \ No newline at end of file diff --git a/apps/keytimer/timerview.js b/apps/keytimer/timerview.js new file mode 100644 index 000000000..48c896ba0 --- /dev/null +++ b/apps/keytimer/timerview.js @@ -0,0 +1,107 @@ +let common; + +function drawButtons() { + //Draw the backdrop + const BAR_TOP = g.getHeight() - 24; + g.setColor(0, 0, 1).setFontAlign(0, -1) + .clearRect(0, BAR_TOP, g.getWidth(), g.getHeight()) + .fillRect(0, BAR_TOP, g.getWidth(), g.getHeight()) + .setColor(1, 1, 1) + .drawLine(g.getWidth() / 2, BAR_TOP, g.getWidth() / 2, g.getHeight()) + + //Draw the buttons + .drawImage(common.BUTTON_ICONS.reset, g.getWidth() / 4, BAR_TOP); + if (common.state.running) { + g.drawImage(common.BUTTON_ICONS.pause, g.getWidth() * 3 / 4, BAR_TOP); + } else { + g.drawImage(common.BUTTON_ICONS.play, g.getWidth() * 3 / 4, BAR_TOP); + } +} + +function drawTimer() { + let timeLeft = common.getTimeLeft(); + g.reset() + .setFontAlign(0, 0) + .setFont("Vector", 36) + .clearRect(0, 24, 176, 152) + + //Draw the timer + .drawString((() => { + let hours = timeLeft / 3600000; + let minutes = (timeLeft % 3600000) / 60000; + let seconds = (timeLeft % 60000) / 1000; + + function pad(number) { + return ('00' + parseInt(number)).slice(-2); + } + + if (hours >= 1) return `${parseInt(hours)}:${pad(minutes)}:${pad(seconds)}`; + else return `${parseInt(minutes)}:${pad(seconds)}`; + })(), g.getWidth() / 2, g.getHeight() / 2) + + if (timeLeft <= 0) load('keytimer-ring.js'); +} + +let timerInterval; + +function setupTimerInterval() { + if (timerInterval !== undefined) { + clearInterval(timerInterval); + } + setTimeout(() => { + timerInterval = setInterval(drawTimer, 1000); + drawTimer(); + }, common.timeLeft % 1000); +} + +exports.show = function (callerCommon) { + common = callerCommon; + drawButtons(); + drawTimer(); + if (common.state.running) { + setupTimerInterval(); + } +} + +function clearTimerInterval() { + if (timerInterval !== undefined) { + clearInterval(timerInterval); + timerInterval = undefined; + } +} + +exports.touch = (button, xy) => { + if (xy.y < 152) return; + + if (button == 1) { + //Reset the timer + let setTime = common.state.setTime; + let inputString = common.state.inputString; + common.state = common.STATE_DEFAULT; + common.state.setTime = setTime; + common.state.inputString = inputString; + clearTimerInterval(); + require('keytimer-keys.js').show(common); + } else { + if (common.state.running) { + //Record the exact moment that we paused + let now = (new Date()).getTime(); + common.state.pausedTime = now; + + //Stop the timer + common.state.running = false; + clearTimerInterval(); + drawTimer(); + drawButtons(); + } else { + //Start the timer and record when we started + let now = (new Date()).getTime(); + common.state.elapsedTime += common.state.pausedTime - common.state.startTime; + common.state.startTime = now; + common.state.running = true; + drawTimer(); + setupTimerInterval(); + drawButtons(); + } + } +}; \ No newline at end of file diff --git a/apps/launch/ChangeLog b/apps/launch/ChangeLog index 65f671bbd..5da1b2661 100644 --- a/apps/launch/ChangeLog +++ b/apps/launch/ChangeLog @@ -17,3 +17,5 @@ 0.15: Support for unload and quick return to the clock on 2v16 0.16: Use a cache of app.info files to speed up loading the launcher 0.17: Don't display 'Loading...' now the watch has its own loading screen +0.18: Add 'back' icon in top-left to go back to clock +0.19: Fix regression after back button added (returnToClock was called twice!) diff --git a/apps/launch/app.js b/apps/launch/app.js index 3b33e530a..b8e598f73 100644 --- a/apps/launch/app.js +++ b/apps/launch/app.js @@ -41,6 +41,17 @@ let apps = launchCache.apps; // Now apps list is loaded - render if (!settings.fullscreen) Bangle.loadWidgets(); + +let returnToClock = function() { + // unload everything manually + // ... or we could just call `load();` but it will be slower + Bangle.setUI(); // remove scroller's handling + if (lockTimeout) clearTimeout(lockTimeout); + Bangle.removeListener("lock", lockHandler); + // now load the default clock - just call .bootcde as this has the code already + setTimeout(eval,0,s.read(".bootcde")); +} + E.showScroller({ h : 64*scaleval, c : apps.length, draw : (i, r) => { @@ -62,26 +73,11 @@ E.showScroller({ } else { load(app.src); } - } + }, + back : returnToClock // button press or tap in top left calls returnToClock now }); g.flip(); // force a render before widgets have finished drawing -let returnToClock = function() { - // unload everything manually - // ... or we could just call `load();` but it will be slower - Bangle.setUI(); // remove scroller's handling - if (lockTimeout) clearTimeout(lockTimeout); - Bangle.removeListener("lock", lockHandler); - // now load the default clock - just call .bootcde as this has the code already - setTimeout(eval,0,s.read(".bootcde")); -} - -// on bangle.js 2, the screen is used for navigating, so the single button goes back -// on bangle.js 1, the buttons are used for navigating -if (process.env.HWVERSION==2) { - setWatch(returnToClock, BTN1, {edge:"falling"}); -} - // 10s of inactivity goes back to clock Bangle.setLocked(false); // unlock initially let lockTimeout; diff --git a/apps/launch/metadata.json b/apps/launch/metadata.json index 8d6d90fb1..ce9b1f801 100644 --- a/apps/launch/metadata.json +++ b/apps/launch/metadata.json @@ -2,7 +2,7 @@ "id": "launch", "name": "Launcher", "shortName": "Launcher", - "version": "0.17", + "version": "0.19", "description": "This is needed to display a menu allowing you to choose your own applications. You can replace this with a customised launcher.", "readme": "README.md", "icon": "app.png", diff --git a/apps/medicalinfo/ChangeLog b/apps/medicalinfo/ChangeLog new file mode 100644 index 000000000..e8739a121 --- /dev/null +++ b/apps/medicalinfo/ChangeLog @@ -0,0 +1 @@ +0.01: Initial Medical Information application! diff --git a/apps/medicalinfo/README.md b/apps/medicalinfo/README.md new file mode 100644 index 000000000..6dd19d4c6 --- /dev/null +++ b/apps/medicalinfo/README.md @@ -0,0 +1,27 @@ +# Medical Information + +This app displays basic medical information, and provides a common way to set up the `medicalinfo.json` file, which other apps can use if required. + +## Medical information JSON file + +When the app is loaded from the app loader, a file named `medicalinfo.json` is loaded along with the javascript etc. +The file has the following contents: + +``` +{ + "bloodType": "", + "height": "", + "weight": "", + "medicalAlert": [ "" ] +} +``` + +## Medical information editor + +Clicking on the download icon of `Medical Information` in the app loader invokes the editor. +The editor downloads and displays the current `medicalinfo.json` file, which can then be edited. +The edited `medicalinfo.json` file is uploaded to the Bangle by clicking the `Upload` button. + +## Creator + +James Taylor ([jt-nti](https://github.com/jt-nti)) diff --git a/apps/medicalinfo/app-icon.js b/apps/medicalinfo/app-icon.js new file mode 100644 index 000000000..1ae7916fb --- /dev/null +++ b/apps/medicalinfo/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwg+7kUiCykCC4MgFykgDIIXUAQgAMiMRiREBC4YABkILBCxEBC4pHCC4kQFxIXEAAgXCGBERif/+QXHl//mIXJj//+YXHn//+IXL/8yCwsjBIIXNABIX/C63d7oDB+czmaPPC7hHR/oWBAAPfC65HRC7qnXX/4XDABAXkIIQAFI5wXXL/5f/L/5fvC9sTC5cxC5IAOC48BCxsQC44wOCxAArA")) diff --git a/apps/medicalinfo/app.js b/apps/medicalinfo/app.js new file mode 100644 index 000000000..9c4941744 --- /dev/null +++ b/apps/medicalinfo/app.js @@ -0,0 +1,61 @@ +const medicalinfo = require('medicalinfo').load(); +// const medicalinfo = { +// bloodType: "O+", +// height: "166cm", +// weight: "73kg" +// }; + +function hasAlert(info) { + return (Array.isArray(info.medicalAlert)) && (info.medicalAlert[0]); +} + +// No space for widgets! +// TODO: no padlock widget visible so prevent screen locking? + +g.clear(); +const bodyFont = g.getFonts().includes("12x20") ? "12x20" : "6x8:2"; +g.setFont(bodyFont); + +const title = hasAlert(medicalinfo) ? "MEDICAL ALERT" : "Medical Information"; +var lines = []; + +lines = g.wrapString(title, g.getWidth() - 10); +var titleCnt = lines.length; +if (titleCnt) lines.push(""); // add blank line after title + +if (hasAlert(medicalinfo)) { + medicalinfo.medicalAlert.forEach(function (details) { + lines = lines.concat(g.wrapString(details, g.getWidth() - 10)); + }); + lines.push(""); // add blank line after medical alert +} + +if (medicalinfo.bloodType) { + lines = lines.concat(g.wrapString("Blood group: " + medicalinfo.bloodType, g.getWidth() - 10)); +} +if (medicalinfo.height) { + lines = lines.concat(g.wrapString("Height: " + medicalinfo.height, g.getWidth() - 10)); +} +if (medicalinfo.weight) { + lines = lines.concat(g.wrapString("Weight: " + medicalinfo.weight, g.getWidth() - 10)); +} + +lines.push(""); + +// TODO: display instructions for updating medical info if there is none! + +E.showScroller({ + h: g.getFontHeight(), // height of each menu item in pixels + c: lines.length, // number of menu items + // a function to draw a menu item + draw: function (idx, r) { + // FIXME: in 2v13 onwards, clearRect(r) will work fine. There's a bug in 2v12 + g.setBgColor(idx < titleCnt ? g.theme.bg2 : g.theme.bg). + setColor(idx < titleCnt ? g.theme.fg2 : g.theme.fg). + clearRect(r.x, r.y, r.x + r.w, r.y + r.h); + g.setFont(bodyFont).drawString(lines[idx], r.x, r.y); + } +}); + +// Show launcher when button pressed +setWatch(() => load(), process.env.HWVERSION === 2 ? BTN : BTN3, { repeat: false, edge: "falling" }); diff --git a/apps/medicalinfo/app.png b/apps/medicalinfo/app.png new file mode 100644 index 000000000..16204ea89 Binary files /dev/null and b/apps/medicalinfo/app.png differ diff --git a/apps/medicalinfo/interface.html b/apps/medicalinfo/interface.html new file mode 100644 index 000000000..9376be32f --- /dev/null +++ b/apps/medicalinfo/interface.html @@ -0,0 +1,135 @@ + + + + + + + +
+ + + + + +

+
+    
+    
+  
+
diff --git a/apps/medicalinfo/lib.js b/apps/medicalinfo/lib.js
new file mode 100644
index 000000000..683005359
--- /dev/null
+++ b/apps/medicalinfo/lib.js
@@ -0,0 +1,21 @@
+const storage = require('Storage');
+
+exports.load = function () {
+  const medicalinfo = storage.readJSON('medicalinfo.json') || {
+    bloodType: "",
+    height: "",
+    weight: "",
+    medicalAlert: [""]
+  };
+
+  // Don't return anything unexpected
+  const expectedMedicalinfo = [
+    "bloodType",
+    "height",
+    "weight",
+    "medicalAlert"
+  ].filter(key => key in medicalinfo)
+    .reduce((obj, key) => (obj[key] = medicalinfo[key], obj), {});
+
+  return expectedMedicalinfo;
+};
diff --git a/apps/medicalinfo/medicalinfo.json b/apps/medicalinfo/medicalinfo.json
new file mode 100644
index 000000000..8b49725cb
--- /dev/null
+++ b/apps/medicalinfo/medicalinfo.json
@@ -0,0 +1,6 @@
+{
+    "bloodType": "",
+    "height": "",
+    "weight": "",
+    "medicalAlert": [ "" ]
+}
diff --git a/apps/medicalinfo/metadata.json b/apps/medicalinfo/metadata.json
new file mode 100644
index 000000000..f1a0c145f
--- /dev/null
+++ b/apps/medicalinfo/metadata.json
@@ -0,0 +1,20 @@
+{ "id": "medicalinfo",
+  "name": "Medical Information",
+  "version":"0.01",
+  "description": "Provides 'medicalinfo.json' used by various health apps, as well as a way to edit it from the App Loader",
+  "icon": "app.png",
+  "tags": "health,medical",
+  "type": "app",
+  "supports" : ["BANGLEJS","BANGLEJS2"],
+  "readme": "README.md",
+  "screenshots": [{"url":"screenshot_light.png"}],
+  "interface": "interface.html",
+  "storage": [
+    {"name":"medicalinfo.app.js","url":"app.js"},
+    {"name":"medicalinfo.img","url":"app-icon.js","evaluate":true},
+    {"name":"medicalinfo","url":"lib.js"}
+  ],
+  "data": [
+    {"name":"medicalinfo.json","url":"medicalinfo.json"}
+  ]
+}
diff --git a/apps/medicalinfo/screenshot_light.png b/apps/medicalinfo/screenshot_light.png
new file mode 100644
index 000000000..42970f9fc
Binary files /dev/null and b/apps/medicalinfo/screenshot_light.png differ
diff --git a/apps/palikkainen/ChangeLog b/apps/palikkainen/ChangeLog
new file mode 100644
index 000000000..5560f00bc
--- /dev/null
+++ b/apps/palikkainen/ChangeLog
@@ -0,0 +1 @@
+0.01: New App!
diff --git a/apps/palikkainen/README.md b/apps/palikkainen/README.md
new file mode 100644
index 000000000..81d857209
--- /dev/null
+++ b/apps/palikkainen/README.md
@@ -0,0 +1,7 @@
+# Palikkainen
+
+By Jukio Kallio
+
+A minimal watch face consisting of blocks. Minutes fills the blocks, and after 12 hours it starts to empty them.
+
+![](screenshot1.png) 
diff --git a/apps/palikkainen/app-icon.js b/apps/palikkainen/app-icon.js
new file mode 100644
index 000000000..a99602121
--- /dev/null
+++ b/apps/palikkainen/app-icon.js
@@ -0,0 +1 @@
+require("heatshrink").decompress(atob("mEwwkBiIA0/4AKCpMfCxYAB+ItTGJQuOGBAWPGAwuQGAwXvCyJgFC+PwgAAEh4X/C/6//A4gX/C/6//A4QX/C/6/vC6sfCyPxC+ZgSCwgwRFwowRCwwwPFw4xOCpIArA"))
diff --git a/apps/palikkainen/app.js b/apps/palikkainen/app.js
new file mode 100644
index 000000000..42013af69
--- /dev/null
+++ b/apps/palikkainen/app.js
@@ -0,0 +1,184 @@
+// Palikkainen
+//
+// Bangle.js 2 watch face
+// by Jukio Kallio
+// www.jukiokallio.com
+
+require("Font6x8").add(Graphics);
+
+// settings
+const watch = {  
+  x:0, y:0, w:0, h:0, 
+  bgcolor:g.theme.bg, 
+  fgcolor:g.theme.fg, 
+  font: "6x8", fontsize: 1,
+  finland:true, // change if you want Finnish style date, or US style
+};
+
+// set some additional settings
+watch.w = g.getWidth(); // size of the background
+watch.h = g.getHeight();
+watch.x = watch.w * 0.5; // position of the circles
+watch.y = watch.h * 0.45;
+
+const dateWeekday = { 0: "SUN", 1: "MON", 2: "TUE", 3: "WED", 4:"THU", 5:"FRI", 6:"SAT" }; // weekdays
+
+var wait = 60000; // wait time, normally a minute
+
+
+// timeout used to update every minute
+var drawTimeout;
+
+// schedule a draw for the next minute
+function queueDraw() {
+  if (drawTimeout) clearTimeout(drawTimeout);
+  drawTimeout = setTimeout(function() {
+    drawTimeout = undefined;
+    draw();
+  }, wait - (Date.now() % wait));
+}
+
+
+// main function
+function draw() {
+  // make date object
+  var date = new Date();
+  
+  // work out the date string
+  var dateDay = date.getDate();
+  var dateMonth = date.getMonth() + 1;
+  var dateYear = date.getFullYear();
+  var dateStr = dateWeekday[date.getDay()] + " " + dateMonth + "." + dateDay + "." + dateYear;
+  if (watch.finland) dateStr = dateWeekday[date.getDay()] + " " + dateDay + "." + dateMonth + "." + dateYear; // the true way of showing date
+
+  // Reset the state of the graphics library
+  g.reset();
+  
+  // Clear the area where we want to draw the time
+  g.setColor(watch.bgcolor);
+  g.fillRect(0, 0, watch.w, watch.h);
+  
+  // setup watch face
+  const block = {
+    w: watch.w / 2 - 6,
+    h: 18,
+    pad: 4,
+  };
+  
+  // get hours and minutes
+  var hour = date.getHours();
+  var minute = date.getMinutes();
+  
+  // calculate size of the block face
+  var facew = block.w * 2 + block.pad;
+  var faceh = (block.h + block.pad) * 6;
+  
+  
+  // loop through first 12 hours and draw blocks accordingly
+  g.setColor(watch.fgcolor); // set foreground color
+  
+  for (var i = 0; i < 12; i++) {
+    // where to draw
+    var x = watch.x - facew / 2; // starting position
+    var y = watch.y + faceh / 2 - block.h - block.pad / 2; // draw blocks from bottom up
+    if (i > 5) { 
+      // second column
+      x += block.w + block.pad;
+      y -= (block.h + block.pad) * (i - 6);
+    } else { 
+      // first column
+      x += 0;
+      y -= (block.h + block.pad) * i;
+    }
+    
+    if (i < hour) {
+      // draw full hour block
+      g.fillRect(x, y, x + block.w, y + block.h);
+    } else if (i == hour) {
+      // draw minutes
+      g.fillRect(x, y, x + block.w * (minute / 60), y + block.h);
+      
+      // minute reading help
+      for (var m = 1; m < 12; m++) {
+        // set color
+        if (m * 5 < minute) g.setColor(watch.bgcolor); else g.setColor(watch.fgcolor);
+
+        var mlineh = 1; // minute line height
+        if (m == 3 || m == 6 || m == 9) mlineh = 3; // minute line height at 15, 30 and 45 minutes
+
+        g.drawLine(x + (block.w / 12 * m), y + block.h / 2 - mlineh, x + (block.w / 12 * m), y + block.h / 2 + mlineh);
+      }
+    }
+  }
+  
+  
+  // loop through second 12 hours and draw blocks accordingly
+  if (hour >= 12) {
+    g.setColor(watch.bgcolor); // set foreground color
+    
+    for (var i2 = 0; i2 < 12; i2++) {
+      // where to draw
+      var x2 = watch.x - facew / 2; // starting position
+      var y2 = watch.y + faceh / 2 - block.h - block.pad / 2; // draw blocks from bottom up
+      if (i2 > 5) { 
+        // second column
+        x2 += block.w + block.pad;
+        y2 -= (block.h + block.pad) * (i2 - 6);
+      } else { 
+        // first column
+        x2 += 0;
+        y2 -= (block.h + block.pad) * i2;
+      }
+
+      if (i2 < hour % 12) {
+        // draw full hour block
+        g.fillRect(x2, y2, x2 + block.w, y2 + block.h);
+      } else if (i2 == hour % 12) {
+        // draw minutes
+        g.fillRect(x2, y2, x2 + block.w * (minute / 60), y2 + block.h);
+        
+        // minute reading help
+        for (var m2 = 1; m2 < 12; m2++) {
+          // set color
+          if (m2 * 5 < minute) g.setColor(watch.fgcolor); else g.setColor(watch.bgcolor);
+          
+          var mlineh2 = 1; // minute line height
+          if (m2 == 3 || m2 == 6 || m2 == 9) mlineh2 = 3; // minute line height at 15, 30 and 45 minutes
+          
+          g.drawLine(x2 + (block.w / 12 * m2), y2 + block.h / 2 - mlineh2, x2 + (block.w / 12 * m2), y2 + block.h / 2 + mlineh2);
+        }
+      }
+    }
+  }
+  
+  
+  // draw date
+  var datey = 11;
+  g.setFontAlign(0,-1).setFont(watch.font, watch.fontsize).setColor(watch.fgcolor);
+  g.drawString(dateStr, watch.x, watch.y + faceh / 2 + datey);
+  
+  
+  // queue draw
+  queueDraw();
+}
+
+
+// Clear the screen once, at startup
+g.clear();
+// draw immediately at first
+draw();
+
+
+// Stop updates when LCD is off, restart when on
+Bangle.on('lcdPower',on=>{
+  if (on) {
+    draw(); // draw immediately, queue redraw
+  } else { // stop draw timer
+    if (drawTimeout) clearTimeout(drawTimeout);
+    drawTimeout = undefined;
+  }
+});
+
+
+// Show launcher when middle button pressed
+Bangle.setUI("clock");
diff --git a/apps/palikkainen/app.png b/apps/palikkainen/app.png
new file mode 100644
index 000000000..142d429e9
Binary files /dev/null and b/apps/palikkainen/app.png differ
diff --git a/apps/palikkainen/metadata.json b/apps/palikkainen/metadata.json
new file mode 100644
index 000000000..4ed8be817
--- /dev/null
+++ b/apps/palikkainen/metadata.json
@@ -0,0 +1,16 @@
+{ "id": "palikkainen",
+  "name": "Palikkainen - A blocky watch face",
+  "shortName":"Palikkainen",
+  "version":"0.01",
+  "description": "A minimal watch face consisting of blocks.",
+  "icon": "app.png",
+  "screenshots": [{"url":"screenshot1.png"}],
+  "type": "clock",
+  "tags": "clock",
+  "supports" : ["BANGLEJS","BANGLEJS2"],  
+  "readme": "README.md",
+  "storage": [
+    {"name":"palikkainen.app.js","url":"app.js"},
+    {"name":"palikkainen.img","url":"app-icon.js","evaluate":true}
+  ]
+}
diff --git a/apps/palikkainen/screenshot1.png b/apps/palikkainen/screenshot1.png
new file mode 100644
index 000000000..43a630d59
Binary files /dev/null and b/apps/palikkainen/screenshot1.png differ
diff --git a/apps/pisteinen/ChangeLog b/apps/pisteinen/ChangeLog
new file mode 100644
index 000000000..5560f00bc
--- /dev/null
+++ b/apps/pisteinen/ChangeLog
@@ -0,0 +1 @@
+0.01: New App!
diff --git a/apps/pisteinen/README.md b/apps/pisteinen/README.md
new file mode 100644
index 000000000..20e8bf9a1
--- /dev/null
+++ b/apps/pisteinen/README.md
@@ -0,0 +1,7 @@
+# Pisteinen
+
+By Jukio Kallio
+
+A Minimal digital watch face consisting of dots. Big dots for hours, small dots for minutes.
+
+![](screenshot1.png) 
diff --git a/apps/pisteinen/app-icon.js b/apps/pisteinen/app-icon.js
new file mode 100644
index 000000000..d8ad05c50
--- /dev/null
+++ b/apps/pisteinen/app-icon.js
@@ -0,0 +1 @@
+require("heatshrink").decompress(atob("mEwwkDmYA0/4AKCpM/CxYAB+YtTGJQuOGBAWPGAwuQGAwXvCyJgFC+UhiQDNC43ygEAl4DLC4/xBYMfAZYXfI653XX/6//X/6//O5gBKU5gGBAZAXfI66//C7s/CyPzC+ZgSCwgwRFwowRCwwwPFw4xOCpIArA=="))
diff --git a/apps/pisteinen/app.js b/apps/pisteinen/app.js
new file mode 100644
index 000000000..a455875ec
--- /dev/null
+++ b/apps/pisteinen/app.js
@@ -0,0 +1,121 @@
+// Pisteinen
+//
+// Bangle.js 2 watch face
+// by Jukio Kallio
+// www.jukiokallio.com
+
+
+// settings
+const watch = {  
+  x:0, y:0, w:0, h:0, 
+  bgcolor:g.theme.bg, 
+  fgcolor:g.theme.fg, 
+};
+
+// set some additional settings
+watch.w = g.getWidth(); // size of the background
+watch.h = g.getHeight();
+watch.x = watch.w * 0.5; // position of the circles
+watch.y = watch.h * 0.5;
+
+var wait = 60000; // wait time, normally a minute
+
+
+// timeout used to update every minute
+var drawTimeout;
+
+// schedule a draw for the next minute
+function queueDraw() {
+  if (drawTimeout) clearTimeout(drawTimeout);
+  drawTimeout = setTimeout(function() {
+    drawTimeout = undefined;
+    draw();
+  }, wait - (Date.now() % wait));
+}
+
+
+// main function
+function draw() {
+  // make date object
+  var date = new Date();
+
+  // Reset the state of the graphics library
+  g.reset();
+  
+  // Clear the area where we want to draw the time
+  g.setColor(watch.bgcolor);
+  g.fillRect(0, 0, watch.w, watch.h);
+  
+  // setup watch face
+  const hball = {
+    size: 9,
+    pad: 9,
+  };
+  const mball = {
+    size: 3,
+    pad: 4,
+    pad2: 2, 
+  };
+  
+  // get hours and minutes
+  var hour = date.getHours();
+  var minute = date.getMinutes();
+  
+  // calculate size of the hour face
+  var hfacew = (hball.size * 2 + hball.pad) * 6 - hball.pad;
+  var hfaceh = (hball.size * 2 + hball.pad) * 4 - hball.pad;
+  var mfacew = (mball.size * 2 + mball.pad) * 15 - mball.pad + mball.pad2 * 2;
+  var mfaceh = (mball.size * 2 + mball.pad) * 4 - mball.pad;
+  var faceh = hfaceh + mfaceh + hball.pad + mball.pad;
+  
+  g.setColor(watch.fgcolor); // set foreground color
+  
+  // draw hour balls
+  for (var i = 0; i < 24; i++) {
+    var x = ((hball.size * 2 + hball.pad) * (i % 6)) + (watch.x - hfacew / 2) + hball.size;
+    var y = watch.y - faceh / 2 + hball.size;
+    if (i >= 6) y += hball.size * 2 + hball.pad;
+    if (i >= 12) y += hball.size * 2 + hball.pad;
+    if (i >= 18) y += hball.size * 2 + hball.pad;
+    
+    if (i < hour) g.fillCircle(x, y, hball.size); else g.drawCircle(x, y, hball.size);
+  }
+  
+  // draw minute balls
+  for (var j = 0; j < 60; j++) {
+    var x2 = ((mball.size * 2 + mball.pad) * (j % 15)) + (watch.x - mfacew / 2) + mball.size;
+    if (j % 15 >= 5) x2 += mball.pad2;
+    if (j % 15 >= 10) x2 += mball.pad2;
+    var y2 = watch.y - faceh / 2 + hfaceh + mball.size + hball.pad + mball.pad;
+    if (j >= 15) y2 += mball.size * 2 + mball.pad;
+    if (j >= 30) y2 += mball.size * 2 + mball.pad;
+    if (j >= 45) y2 += mball.size * 2 + mball.pad;
+    
+    if (j < minute) g.fillCircle(x2, y2, mball.size); else g.drawCircle(x2, y2, mball.size);
+  }
+  
+  
+  // queue draw
+  queueDraw();
+}
+
+
+// Clear the screen once, at startup
+g.clear();
+// draw immediately at first
+draw();
+
+
+// Stop updates when LCD is off, restart when on
+Bangle.on('lcdPower',on=>{
+  if (on) {
+    draw(); // draw immediately, queue redraw
+  } else { // stop draw timer
+    if (drawTimeout) clearTimeout(drawTimeout);
+    drawTimeout = undefined;
+  }
+});
+
+
+// Show launcher when middle button pressed
+Bangle.setUI("clock");
diff --git a/apps/pisteinen/app.png b/apps/pisteinen/app.png
new file mode 100644
index 000000000..a6c441423
Binary files /dev/null and b/apps/pisteinen/app.png differ
diff --git a/apps/pisteinen/metadata.json b/apps/pisteinen/metadata.json
new file mode 100644
index 000000000..f1137e589
--- /dev/null
+++ b/apps/pisteinen/metadata.json
@@ -0,0 +1,16 @@
+{ "id": "pisteinen",
+  "name": "Pisteinen - Dotted watch face",
+  "shortName":"Pisteinen",
+  "version":"0.01",
+  "description": "A minimal digital watch face made with dots.",
+  "icon": "app.png",
+  "screenshots": [{"url":"screenshot1.png"}],
+  "type": "clock",
+  "tags": "clock",
+  "supports" : ["BANGLEJS","BANGLEJS2"],  
+  "readme": "README.md",
+  "storage": [
+    {"name":"pisteinen.app.js","url":"app.js"},
+    {"name":"pisteinen.img","url":"app-icon.js","evaluate":true}
+  ]
+}
diff --git a/apps/pisteinen/screenshot1.png b/apps/pisteinen/screenshot1.png
new file mode 100644
index 000000000..556c004c0
Binary files /dev/null and b/apps/pisteinen/screenshot1.png differ
diff --git a/apps/poikkipuinen/ChangeLog b/apps/poikkipuinen/ChangeLog
new file mode 100644
index 000000000..5560f00bc
--- /dev/null
+++ b/apps/poikkipuinen/ChangeLog
@@ -0,0 +1 @@
+0.01: New App!
diff --git a/apps/poikkipuinen/README.md b/apps/poikkipuinen/README.md
new file mode 100644
index 000000000..12f8d5d7e
--- /dev/null
+++ b/apps/poikkipuinen/README.md
@@ -0,0 +1,7 @@
+# Poikkipuinen
+
+By Jukio Kallio
+
+A Minimal digital watch face. Follows the theme colors.
+
+![](screenshot1.png) 
diff --git a/apps/poikkipuinen/app-icon.js b/apps/poikkipuinen/app-icon.js
new file mode 100644
index 000000000..d7ddba399
--- /dev/null
+++ b/apps/poikkipuinen/app-icon.js
@@ -0,0 +1 @@
+require("heatshrink").decompress(atob("mEwwkEogA0/4AKCpNPCxYAB+gtTGJQuOGBAWPGAwuQGAwXamQULkYXGBQUgn4WJ+cCMAwXNiQXV+MBC6swh4XU+cAn4XU+IUBC6kgj4XUIwKnV+EDC6sQl4XU+UBd6q8BC6q8BC6i8CC6i8CC6a8DC6a8DC6a8DC6S8EC6S8EC6S8EC6K8FC6K8FC6C8BIwwXOXgwXQXgwXQkIWHd6IXPp4GBmQWJAAMjAQP0C4wAPC7hgDABwWEGCIuFGCIWGGB4uHGJwVJAFY="))
diff --git a/apps/poikkipuinen/app.js b/apps/poikkipuinen/app.js
new file mode 100644
index 000000000..0bf09c5e5
--- /dev/null
+++ b/apps/poikkipuinen/app.js
@@ -0,0 +1,158 @@
+// Poikkipuinen
+//
+// Bangle.js 2 watch face
+// by Jukio Kallio
+// www.jukiokallio.com
+
+require("Font5x9Numeric7Seg").add(Graphics);
+require("FontSinclair").add(Graphics);
+
+// settings
+const watch = {  
+  x:0, y:0, w:0, h:0, 
+  bgcolor:g.theme.bg, 
+  fgcolor:g.theme.fg, 
+  font: "5x9Numeric7Seg", fontsize: 1,
+  font2: "Sinclair", font2size: 1, 
+  finland:true, // change if you want Finnish style date, or US style
+};
+
+
+// set some additional settings
+watch.w = g.getWidth(); // size of the background
+watch.h = g.getHeight();
+watch.x = watch.w * 0.5; // position of the circles
+watch.y = watch.h * 0.41;
+
+const dateWeekday = { 0: "SUN", 1: "MON", 2: "TUE", 3: "WED", 4:"THU", 5:"FRI", 6:"SAT" }; // weekdays
+
+var wait = 60000; // wait time, normally a minute
+
+
+// timeout used to update every minute
+var drawTimeout;
+
+// schedule a draw for the next minute
+function queueDraw() {
+  if (drawTimeout) clearTimeout(drawTimeout);
+  drawTimeout = setTimeout(function() {
+    drawTimeout = undefined;
+    draw();
+  }, wait - (Date.now() % wait));
+}
+
+
+// main function
+function draw() {
+  // make date object
+  var date = new Date();
+  
+  // work out the date string
+  var dateDay = date.getDate();
+  var dateMonth = date.getMonth() + 1;
+  var dateYear = date.getFullYear();
+  var dateStr = dateMonth + "." + dateDay + "." + dateYear;
+  if (watch.finland) dateStr = dateDay + "." + dateMonth + "." + dateYear; // the true way of showing date
+  var dateStr2 = dateWeekday[date.getDay()];
+
+  // Reset the state of the graphics library
+  g.reset();
+  
+  // Clear the area where we want to draw the time
+  g.setColor(watch.bgcolor);
+  g.fillRect(0, 0, watch.w, watch.h);
+  
+  // set foreground color
+  g.setColor(watch.fgcolor);
+  g.setFontAlign(1,-1).setFont(watch.font, watch.fontsize);
+  
+  // watch face size
+  var facew, faceh; // halves of the size for easier calculation
+  facew = 50;
+  faceh = 59;
+  
+  // save hour and minute y positions
+  var houry, minutey;
+  
+  // draw hour meter
+  g.drawLine(watch.x - facew, watch.y - faceh, watch.x - facew, watch.y + faceh);
+  var lines = 13;
+  var lineh = faceh * 2 / (lines - 2);
+  for (var i = 1; i < lines; i++) {
+    var w = 3;
+    var y = faceh - lineh * (i - 1);
+    
+    if (i % 3 == 0) {
+      // longer line and numbers every 3
+      w = 5;
+      g.drawString(i, watch.x - facew - 2, y + watch.y);
+    }
+    
+    g.drawLine(watch.x - facew, y + watch.y, watch.x - facew + w, y + watch.y);
+    
+    // get hour y position
+    var hour = date.getHours() % 12; // modulate away the 24h
+    if (hour == 0) hour = 12; // fix a problem with 0-23 hours
+    //var hourMin = date.getMinutes() / 60; // move hour line by minutes
+    var hourMin = Math.floor(date.getMinutes() / 15) / 4; // move hour line by 15-minutes
+    if (hour == 12) hourMin = 0; // don't do minute moving if 12 (line ends there)
+    if (i == hour) houry = y - (lineh * hourMin);
+  }
+  
+  // draw minute meter
+  g.drawLine(watch.x + facew, watch.y - faceh, watch.x + facew, watch.y + faceh);
+  g.setFontAlign(-1,-1);
+  lines = 60;
+  lineh = faceh * 2 / (lines - 1);
+  for (i = 0; i < lines; i++) {
+    var mw = 3;
+    var my = faceh - lineh * i;
+    
+    if (i % 15 == 0 && i != 0) {
+      // longer line and numbers every 3
+      mw = 5;
+      g.drawString(i, watch.x + facew + 4, my + watch.y);
+    }
+    
+    //if (i % 2 == 0 || i == 15 || i == 45) 
+    g.drawLine(watch.x + facew, my + watch.y, watch.x + facew - mw, my + watch.y);
+    
+    // get minute y position
+    if (i == date.getMinutes()) minutey = my;
+  }
+  
+  // draw the time
+  var timexpad = 8;
+  g.drawLine(watch.x - facew + timexpad, watch.y + houry, watch.x + facew - timexpad, watch.y + minutey);
+  
+  // draw date
+  var datey = 14;
+  g.setFontAlign(0,-1);
+  g.drawString(dateStr, watch.x, watch.y + faceh + datey);
+  g.setFontAlign(0,-1).setFont(watch.font2, watch.font2size);
+  g.drawString(dateStr2, watch.x, watch.y + faceh + datey*2);
+  
+  // queue draw
+  queueDraw();
+}
+
+
+// Clear the screen once, at startup
+g.clear();
+// draw immediately at first
+draw();
+
+
+// Stop updates when LCD is off, restart when on
+Bangle.on('lcdPower',on=>{
+  if (on) {
+    draw(); // draw immediately, queue redraw
+  } else { // stop draw timer
+    if (drawTimeout) clearTimeout(drawTimeout);
+    drawTimeout = undefined;
+  }
+});
+
+
+// Show launcher when middle button pressed
+Bangle.setUI("clock");
diff --git a/apps/poikkipuinen/app.png b/apps/poikkipuinen/app.png
new file mode 100644
index 000000000..fa506c886
Binary files /dev/null and b/apps/poikkipuinen/app.png differ
diff --git a/apps/poikkipuinen/metadata.json b/apps/poikkipuinen/metadata.json
new file mode 100644
index 000000000..ec95ab7ce
--- /dev/null
+++ b/apps/poikkipuinen/metadata.json
@@ -0,0 +1,16 @@
+{ "id": "poikkipuinen",
+  "name": "Poikkipuinen - Minimal watch face",
+  "shortName":"Poikkipuinen",
+  "version":"0.01",
+  "description": "A minimal digital watch face.",
+  "icon": "app.png",
+  "screenshots": [{"url":"screenshot1.png"}],
+  "type": "clock",
+  "tags": "clock",
+  "supports" : ["BANGLEJS","BANGLEJS2"],  
+  "readme": "README.md",
+  "storage": [
+    {"name":"poikkipuinen.app.js","url":"app.js"},
+    {"name":"poikkipuinen.img","url":"app-icon.js","evaluate":true}
+  ]
+}
diff --git a/apps/poikkipuinen/screenshot1.png b/apps/poikkipuinen/screenshot1.png
new file mode 100644
index 000000000..23fcc348c
Binary files /dev/null and b/apps/poikkipuinen/screenshot1.png differ
diff --git a/apps/pomoplus/ChangeLog b/apps/pomoplus/ChangeLog
new file mode 100644
index 000000000..1a137aad0
--- /dev/null
+++ b/apps/pomoplus/ChangeLog
@@ -0,0 +1,3 @@
+0.01: New app!
+0.02-0.04: Bug fixes
+0.05: Submitted to the app loader
\ No newline at end of file
diff --git a/apps/pomoplus/app.js b/apps/pomoplus/app.js
new file mode 100644
index 000000000..73af5c935
--- /dev/null
+++ b/apps/pomoplus/app.js
@@ -0,0 +1,157 @@
+Bangle.POMOPLUS_ACTIVE = true;  //Prevent the boot code from running. To avoid having to reload on every interaction, we'll control the vibrations from here when the user is in the app.
+
+const storage = require("Storage");
+const common = require("pomoplus-com.js");
+
+//Expire the state if necessary
+if (
+  common.settings.pausedTimerExpireTime != 0 &&
+  !common.state.running &&
+  (new Date()).getTime() - common.state.pausedTime > common.settings.pausedTimerExpireTime
+) {
+  common.state = common.STATE_DEFAULT;
+}
+
+function drawButtons() {
+  //Draw the backdrop
+  const BAR_TOP = g.getHeight() - 24;
+  g.setColor(0, 0, 1).setFontAlign(0, -1)
+    .clearRect(0, BAR_TOP, g.getWidth(), g.getHeight())
+    .fillRect(0, BAR_TOP, g.getWidth(), g.getHeight())
+    .setColor(1, 1, 1);
+
+  if (!common.state.wasRunning) {  //If the timer was never started, only show a play button
+    g.drawImage(common.BUTTON_ICONS.play, g.getWidth() / 2, BAR_TOP);
+  } else {
+    g.drawLine(g.getWidth() / 2, BAR_TOP, g.getWidth() / 2, g.getHeight());
+    if (common.state.running) {
+      g.drawImage(common.BUTTON_ICONS.pause, g.getWidth() / 4, BAR_TOP)
+        .drawImage(common.BUTTON_ICONS.skip, g.getWidth() * 3 / 4, BAR_TOP);
+    } else {
+      g.drawImage(common.BUTTON_ICONS.reset, g.getWidth() / 4, BAR_TOP)
+        .drawImage(common.BUTTON_ICONS.play, g.getWidth() * 3 / 4, BAR_TOP);
+    }
+  }
+}
+
+function drawTimerAndMessage() {
+  g.reset()
+    .setFontAlign(0, 0)
+    .setFont("Vector", 36)
+    .clearRect(0, 24, 176, 152)
+
+    //Draw the timer
+    .drawString((() => {
+      let timeLeft = common.getTimeLeft();
+      let hours = timeLeft / 3600000;
+      let minutes = (timeLeft % 3600000) / 60000;
+      let seconds = (timeLeft % 60000) / 1000;
+
+      function pad(number) {
+        return ('00' + parseInt(number)).slice(-2);
+      }
+
+      if (hours >= 1) return `${parseInt(hours)}:${pad(minutes)}:${pad(seconds)}`;
+      else return `${parseInt(minutes)}:${pad(seconds)}`;
+    })(), g.getWidth() / 2, g.getHeight() / 2)
+
+    //Draw the phase label
+    .setFont("Vector", 12)
+    .drawString(((currentPhase, numShortBreaks) => {
+      if (!common.state.wasRunning) return "Not started";
+      else if (currentPhase == common.PHASE_WORKING) return `Work ${numShortBreaks + 1}/${common.settings.numShortBreaks + 1}`
+      else if (currentPhase == common.PHASE_SHORT_BREAK) return `Short break ${numShortBreaks + 1}/${common.settings.numShortBreaks}`;
+      else return "Long break!";
+    })(common.state.phase, common.state.numShortBreaks),
+      g.getWidth() / 2, g.getHeight() / 2 + 18);
+
+  //Update phase with vibation if needed
+  if (common.getTimeLeft() <= 0) {
+    common.nextPhase(true);
+  }
+}
+
+drawButtons();
+Bangle.on("touch", (button, xy) => {
+  //If we support full touch and we're not touching the keys, ignore.
+  //If we don't support full touch, we can't tell so just assume we are.
+  if (xy !== undefined && xy.y <= g.getHeight() - 24) return;
+
+  if (!common.state.wasRunning) {
+    //If we were never running, there is only one button: the start button
+    let now = (new Date()).getTime();
+    common.state = {
+      wasRunning: true,
+      running: true,
+      startTime: now,
+      pausedTime: now,
+      elapsedTime: 0,
+      phase: common.PHASE_WORKING,
+      numShortBreaks: 0
+    };
+    setupTimerInterval();
+    drawButtons();
+
+  } else if (common.state.running) {
+    //If we are running, there are two buttons: pause and skip
+    if (button == 1) {
+      //Record the exact moment that we paused
+      let now = (new Date()).getTime();
+      common.state.pausedTime = now;
+
+      //Stop the timer
+      common.state.running = false;
+      clearInterval(timerInterval);
+      timerInterval = undefined;
+      drawTimerAndMessage();
+      drawButtons();
+
+    } else {
+      common.nextPhase(false);
+    }
+
+  } else {
+    //If we are stopped, there are two buttons: Reset and continue
+    if (button == 1) {
+      //Reset the timer
+      common.state = common.STATE_DEFAULT;
+      drawTimerAndMessage();
+      drawButtons();
+
+    } else {
+      //Start the timer and record old elapsed time and when we started
+      let now = (new Date()).getTime();
+      common.state.elapsedTime += common.state.pausedTime - common.state.startTime;
+      common.state.startTime = now;
+      common.state.running = true;
+      drawTimerAndMessage();
+      setupTimerInterval();
+      drawButtons();
+    }
+  }
+});
+
+let timerInterval;
+
+function setupTimerInterval() {
+  if (timerInterval !== undefined) {
+    clearInterval(timerInterval);
+  }
+  setTimeout(() => {
+    timerInterval = setInterval(drawTimerAndMessage, 1000);
+    drawTimerAndMessage();
+  }, common.timeLeft % 1000);
+}
+
+drawTimerAndMessage();
+if (common.state.running) {
+  setupTimerInterval();
+}
+
+//Save our state when the app is closed
+E.on('kill', () => {
+  storage.writeJSON(common.STATE_PATH, common.state);
+});
+
+Bangle.loadWidgets();
+Bangle.drawWidgets();
\ No newline at end of file
diff --git a/apps/pomoplus/boot.js b/apps/pomoplus/boot.js
new file mode 100644
index 000000000..edc233853
--- /dev/null
+++ b/apps/pomoplus/boot.js
@@ -0,0 +1,19 @@
+const POMOPLUS_storage = require("Storage");
+const POMOPLUS_common = require("pomoplus-com.js");
+
+function setNextTimeout() {
+    setTimeout(() => {
+        //Make sure that the pomoplus app isn't in the foreground. The pomoplus app handles the vibrations when it is in the foreground in order to avoid having to reload every time the user changes state. That means that when the app is in the foreground, we shouldn't do anything here.
+        //We do this after the timer rather than before because the timer will start before the app executes.
+        if (Bangle.POMOPLUS_ACTIVE === undefined) {
+            POMOPLUS_common.nextPhase(true);
+            setNextTimeout();
+            POMOPLUS_storage.writeJSON(POMOPLUS_common.STATE_PATH, POMOPLUS_common.state)
+        }
+    }, POMOPLUS_common.getTimeLeft());
+}
+
+//Only start the timeout if the timer is running
+if (POMOPLUS_common.state.running) {
+    setNextTimeout();
+}
\ No newline at end of file
diff --git a/apps/pomoplus/common.js b/apps/pomoplus/common.js
new file mode 100644
index 000000000..b1cd42de8
--- /dev/null
+++ b/apps/pomoplus/common.js
@@ -0,0 +1,118 @@
+const storage = require("Storage");
+const heatshrink = require("heatshrink");
+
+exports.STATE_PATH = "pomoplus.state.json";
+exports.SETTINGS_PATH = "pomoplus.json";
+
+exports.PHASE_WORKING = 0;
+exports.PHASE_SHORT_BREAK = 1;
+exports.PHASE_LONG_BREAK = 2;
+
+exports.BUTTON_ICONS = {
+    play: heatshrink.decompress(atob("jEYwMAkAGBnACBnwCBn+AAQPgAQPwAQP8AQP/AQXAAQPwAQP8AQP+AQgICBwQUCEAn4FggyBHAQ+CIgQ")),
+    pause: heatshrink.decompress(atob("jEYwMA/4BBAX4CEA")),
+    reset: heatshrink.decompress(atob("jEYwMA/4BB/+BAQPDAQPnAQIAKv///0///8j///EP//wAQQICBwQUCEhgyCHAQ+CIgI=")),
+    skip: heatshrink.decompress(atob("jEYwMAwEIgHAhkA8EOgHwh8A/EPwH8h/A/0P8H/h/w/+P/H/5/8//v/3/AAoICBwQUCDQIgCEwQsCGQQ4CHwRECA"))
+};
+
+exports.settings = storage.readJSON(exports.SETTINGS_PATH);
+if (!exports.settings) {
+    exports.settings = {
+        workTime: 1500000,                  //Work for 25 minutes
+        shortBreak: 300000,                 //5 minute short break
+        longBreak: 900000,                  //15 minute long break
+        numShortBreaks: 3,                  //3 short breaks for every long break
+        pausedTimerExpireTime: 21600000,    //If the timer was left paused for >6 hours, reset it on next launch
+        widget: false                       //If a widget is added in the future, whether the user wants it
+    };
+}
+
+//Store the minimal amount of information to be able to reconstruct the state of the timer at any given time.
+//This is necessary because it is necessary to write to flash to let the timer run in the background, so minimizing the writes is necessary.
+exports.STATE_DEFAULT = {
+    wasRunning: false,              //If the timer ever was running. Used to determine whether to display a reset button
+    running: false,                 //Whether the timer is currently running
+    startTime: 0,                   //When the timer was last started. Difference between this and now is how long timer has run continuously.
+    pausedTime: 0,                  //When the timer was last paused. Used for expiration and displaying timer while paused.
+    elapsedTime: 0,                 //How much time the timer had spent running before the current start time. Update on pause or user skipping stages.
+    phase: exports.PHASE_WORKING,   //What phase the timer is currently in
+    numShortBreaks: 0               //Number of short breaks that have occured so far
+};
+exports.state = storage.readJSON(exports.STATE_PATH);
+if (!exports.state) {
+    exports.state = exports.STATE_DEFAULT;
+}
+
+//Get the number of milliseconds until the next phase change
+exports.getTimeLeft = function () {
+    if (!exports.state.wasRunning) {
+        //If the timer never ran, the time left is just the amount of work time.
+        return exports.settings.workTime;
+    } else if (exports.state.running) {
+        //If the timer is running, the time left is current time - start time + preexisting time
+        var runningTime = (new Date()).getTime() - exports.state.startTime + exports.state.elapsedTime;
+    } else {
+        //If the timer is not running, the same as above but use when the timer was paused instead of now.
+        var runningTime = exports.state.pausedTime - exports.state.startTime + exports.state.elapsedTime;
+    }
+
+    if (exports.state.phase == exports.PHASE_WORKING) {
+        return exports.settings.workTime - runningTime;
+    } else if (exports.state.phase == exports.PHASE_SHORT_BREAK) {
+        return exports.settings.shortBreak - runningTime;
+    } else {
+        return exports.settings.longBreak - runningTime;
+    }
+}
+
+//Get the next phase to change to
+exports.getNextPhase = function () {
+    if (exports.state.phase == exports.PHASE_WORKING) {
+        if (exports.state.numShortBreaks < exports.settings.numShortBreaks) {
+            return exports.PHASE_SHORT_BREAK;
+        } else {
+            return exports.PHASE_LONG_BREAK;
+        }
+    } else {
+        return exports.PHASE_WORKING;
+    }
+}
+
+//Change to the next phase and update numShortBreaks, and optionally vibrate. DOES NOT WRITE STATE CHANGE TO STORAGE!
+exports.nextPhase = function (vibrate) {
+    a = {
+        startTime: 0,                   //When the timer was last started. Difference between this and now is how long timer has run continuously.
+        pausedTime: 0,                  //When the timer was last paused. Used for expiration and displaying timer while paused.
+        elapsedTime: 0,                 //How much time the timer had spent running before the current start time. Update on pause or user skipping stages.
+        phase: exports.PHASE_WORKING,   //What phase the timer is currently in
+        numShortBreaks: 0               //Number of short breaks that have occured so far
+    }
+    let now = (new Date()).getTime();
+    exports.state.startTime = now;  //The timer is being reset, so say it starts now.
+    exports.state.pausedTime = now; //This prevents a paused timer from having the start time moved to the future and therefore having been run for negative time.
+    exports.state.elapsedTime = 0;  //Because we are resetting the timer, we no longer need to care about whether it was paused previously.
+
+    let oldPhase = exports.state.phase; //Cache the old phase because we need to remember it when counting the number of short breaks
+    exports.state.phase = exports.getNextPhase();
+
+    if (oldPhase == exports.PHASE_SHORT_BREAK) {
+        //If we just left a short break, increase the number of short breaks
+        exports.state.numShortBreaks++;
+    } else if (oldPhase == exports.PHASE_LONG_BREAK) {
+        //If we just left a long break, set the number of short breaks to zero
+        exports.state.numShortBreaks = 0;
+    }
+
+    if (vibrate) {
+        if (exports.state.phase == exports.PHASE_WORKING) {
+            Bangle.buzz(750, 1);
+        } else if (exports.state.phase == exports.PHASE_SHORT_BREAK) {
+            Bangle.buzz();
+            setTimeout(Bangle.buzz, 400);
+        } else {
+            Bangle.buzz();
+            setTimeout(Bangle.buzz, 400);
+            setTimeout(Bangle.buzz, 600);
+        }
+    }
+}
\ No newline at end of file
diff --git a/apps/pomoplus/icon.js b/apps/pomoplus/icon.js
new file mode 100644
index 000000000..e4ecc7d1c
--- /dev/null
+++ b/apps/pomoplus/icon.js
@@ -0,0 +1 @@
+require("heatshrink").decompress(atob("mEwwcBkmSpICG5AIHCLMmoQOKycJAoUyiQRLAgIOBAQQyKsmSpAROpgEBhmQzIHBC4JTIDIxcHDQYgCBQUSphEGpMJkwcEwgLByBoFCIMyCIgpDL4RQEBwWQ5ICBDoRKCBAIFBNYeSjJHDKYYaCR4YLBiYgDKYo4DEwQgECIpiECISqFkJlCCIILETwYRGDo1CsiJECIiPCdIaqCSoabFCgYRHAQ5iBCJ8hcAgRNKwOQgARLU4IRCvwRMa4QRPfwQR5YooR/cAYOGgAADvwEDCI8H/4AG/Ek5IRXGpMkzJZNoQGByYRNiQJCsgRLyAJDpgRQpIRLwgJEWxARBkIJFUg4RChIJGQA4RJNw4RKLg0kCJQ4DVoUACIY"))
\ No newline at end of file
diff --git a/apps/pomoplus/icon.png b/apps/pomoplus/icon.png
new file mode 100644
index 000000000..60d8023db
Binary files /dev/null and b/apps/pomoplus/icon.png differ
diff --git a/apps/pomoplus/img/pause.png b/apps/pomoplus/img/pause.png
new file mode 100644
index 000000000..ad31dadcf
Binary files /dev/null and b/apps/pomoplus/img/pause.png differ
diff --git a/apps/pomoplus/img/play.png b/apps/pomoplus/img/play.png
new file mode 100644
index 000000000..6c20c24c5
Binary files /dev/null and b/apps/pomoplus/img/play.png differ
diff --git a/apps/pomoplus/img/reset.png b/apps/pomoplus/img/reset.png
new file mode 100644
index 000000000..7a317d097
Binary files /dev/null and b/apps/pomoplus/img/reset.png differ
diff --git a/apps/pomoplus/img/skip.png b/apps/pomoplus/img/skip.png
new file mode 100644
index 000000000..375318069
Binary files /dev/null and b/apps/pomoplus/img/skip.png differ
diff --git a/apps/pomoplus/metadata.json b/apps/pomoplus/metadata.json
new file mode 100644
index 000000000..068eeed91
--- /dev/null
+++ b/apps/pomoplus/metadata.json
@@ -0,0 +1,37 @@
+{
+  "id": "pomoplus",
+  "name": "Pomodoro Plus",
+  "version": "0.05",
+  "description": "A configurable pomodoro timer that runs in the background.",
+  "icon": "icon.png",
+  "type": "app",
+  "tags": "pomodoro,cooking,tools",
+  "supports": [
+    "BANGLEJS",
+    "BANGLEJS2"
+  ],
+  "allow_emulator": true,
+  "storage": [
+    {
+      "name": "pomoplus.app.js",
+      "url": "app.js"
+    },
+    {
+      "name": "pomoplus.img",
+      "url": "icon.js",
+      "evaluate": true
+    },
+    {
+      "name": "pomoplus.boot.js",
+      "url": "boot.js"
+    },
+    {
+      "name": "pomoplus-com.js",
+      "url": "common.js"
+    },
+    {
+      "name": "pomoplus.settings.js",
+      "url": "settings.js"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/apps/pomoplus/settings.js b/apps/pomoplus/settings.js
new file mode 100644
index 000000000..1ff52340a
--- /dev/null
+++ b/apps/pomoplus/settings.js
@@ -0,0 +1,94 @@
+const SETTINGS_PATH = 'pomoplus.json';
+const storage = require("Storage");
+
+(function (back) {
+    let settings = storage.readJSON(SETTINGS_PATH);
+    if (!settings) {
+        settings = {
+            workTime: 1500000,                  //Work for 25 minutes
+            shortBreak: 300000,                 //5 minute short break
+            longBreak: 900000,                  //15 minute long break
+            numShortBreaks: 3,                  //3 short breaks for every long break
+            pausedTimerExpireTime: 21600000,    //If the timer was left paused for >6 hours, reset it on next launch
+            widget: false                       //If a widget is added in the future, whether the user wants it
+        };
+    }
+
+    function save() {
+        storage.writeJSON(SETTINGS_PATH, settings);
+    }
+
+    const menu = {
+        '': { 'title': 'Pomodoro Plus' },
+        '< Back': back,
+        'Work time': {
+            value: settings.workTime,
+            step: 60000,    //1 minute
+            min: 60000,
+            // max: 10800000,
+            // wrap: true,
+            onchange: function (value) {
+                settings.workTime = value;
+                save();
+            },
+            format: function (value) {
+                return '' + (value / 60000) + 'm'
+            }
+        },
+        'Short break time': {
+            value: settings.shortBreak,
+            step: 60000,
+            min: 60000,
+            // max: 10800000,
+            // wrap: true,
+            onchange: function (value) {
+                settings.shortBreak = value;
+                save();
+            },
+            format: function (value) {
+                return '' + (value / 60000) + 'm'
+            }
+        },
+        '# Short breaks': {
+            value: settings.numShortBreaks,
+            step: 1,
+            min: 0,
+            // max: 10800000,
+            // wrap: true,
+            onchange: function (value) {
+                settings.numShortBreaks = value;
+                save();
+            }
+        },
+        'Long break time': {
+            value: settings.longBreak,
+            step: 60000,
+            min: 60000,
+            // max: 10800000,
+            // wrap: true,
+            onchange: function (value) {
+                settings.longBreak = value;
+                save();
+            },
+            format: function (value) {
+                return '' + (value / 60000) + 'm'
+            }
+        },
+        'Timer expiration': {
+            value: settings.pausedTimerExpireTime,
+            step: 900000,   //15 minutes
+            min: 0,
+            // max: 10800000,
+            // wrap: true,
+            onchange: function (value) {
+                settings.pausedTimerExpireTime = value;
+                save();
+            },
+            format: function (value) {
+                if (value == 0) return "Off"
+                else return `${Math.floor(value / 3600000)}h ${(value % 3600000) / 60000}m`
+            }
+        },
+    };
+    E.showMenu(menu)
+})
\ No newline at end of file
diff --git a/apps/powermanager/ChangeLog b/apps/powermanager/ChangeLog
index f0b60a45a..a83e8c676 100644
--- a/apps/powermanager/ChangeLog
+++ b/apps/powermanager/ChangeLog
@@ -1,3 +1,5 @@
 0.01: New App!
 0.02: Allow forcing monotonic battery voltage/percentage
 0.03: Use default Bangle formatter for booleans
+0.04: Remove calibration with current voltage (Calibrate->Auto) as it is now handled by settings app
+      Allow automatic calibration on every charge longer than 3 hours
diff --git a/apps/powermanager/README.md b/apps/powermanager/README.md
index 434ec814e..88b3c370a 100644
--- a/apps/powermanager/README.md
+++ b/apps/powermanager/README.md
@@ -3,8 +3,9 @@
 Manages settings for charging.
 Features:
 * Warning threshold to be able to disconnect the charger at a given percentage
-* Set the battery calibration offset.
+* Set the battery calibration offset
 * Force monotonic battery percentage or voltage
+* Automatic calibration on charging uninterrupted longer than 3 hours (reloads of the watch reset the timer).
 
 ## Internals
 
diff --git a/apps/powermanager/boot.js b/apps/powermanager/boot.js
index 077e24413..2bc2aaa35 100644
--- a/apps/powermanager/boot.js
+++ b/apps/powermanager/boot.js
@@ -5,7 +5,6 @@
   );
   
   if (settings.warnEnabled){
-    print("Charge warning enabled");
     var chargingInterval;
 
     function handleCharging(charging){
@@ -48,4 +47,13 @@
       return v;
     };
   }
+  
+  if (settings.autoCalibration){
+    let chargeStart;
+    Bangle.on("charging", (charging)=>{
+      if (charging) chargeStart = Date.now();
+      if (chargeStart && !charging && (Date.now() - chargeStart > 1000*60*60*3)) require("powermanager").setCalibration();
+      if (!charging) chargeStart = undefined;
+    });
+  }
 })();
diff --git a/apps/powermanager/lib.js b/apps/powermanager/lib.js
new file mode 100644
index 000000000..f4a7e3378
--- /dev/null
+++ b/apps/powermanager/lib.js
@@ -0,0 +1,6 @@
+// set battery calibration value by either applying the given value or setting the currently read battery voltage
+exports.setCalibration = function(calibration){
+  let s = require('Storage').readJSON("setting.json", true) || {};
+  s.batFullVoltage = calibration?calibration:((analogRead(D3) + analogRead(D3) + analogRead(D3) + analogRead(D3)) / 4);
+  require('Storage').writeJSON("setting.json", s);
+}
diff --git a/apps/powermanager/metadata.json b/apps/powermanager/metadata.json
index dd1727657..0777feee3 100644
--- a/apps/powermanager/metadata.json
+++ b/apps/powermanager/metadata.json
@@ -2,7 +2,7 @@
   "id": "powermanager",
   "name": "Power Manager",
   "shortName": "Power Manager",
-  "version": "0.03",
+  "version": "0.04",
   "description": "Allow configuration of warnings and thresholds for battery charging and display.",
   "icon": "app.png",
   "type": "bootloader",
@@ -12,6 +12,7 @@
   "storage": [
     {"name":"powermanager.boot.js","url":"boot.js"},
     {"name":"powermanager.settings.js","url":"settings.js"},
+    {"name":"powermanager","url":"lib.js"},
     {"name":"powermanager.default.json","url":"default.json"}
   ]
 }
diff --git a/apps/powermanager/settings.js b/apps/powermanager/settings.js
index 7cc683024..9eeb29e00 100644
--- a/apps/powermanager/settings.js
+++ b/apps/powermanager/settings.js
@@ -23,7 +23,6 @@
     '': {
       'title': 'Power Manager'
     },
-    '< Back': back,
     'Monotonic percentage': {
       value: !!settings.forceMonoPercentage,
       onchange: v => {
@@ -44,29 +43,29 @@
     }
   };
 
-
   function roundToDigits(number, stepsize) {
     return Math.round(number / stepsize) * stepsize;
   }
 
-  function getCurrentVoltageDirect() {
-    return (analogRead(D3) + analogRead(D3) + analogRead(D3) + analogRead(D3)) / 4;
-  }
-
   var stepsize = 0.0002;
-  var full = 0.32;
+  var full = 0.3144;
 
   function getInitialCalibrationOffset() {
     return roundToDigits(systemsettings.batFullVoltage - full, stepsize) || 0;
   }
 
-
   var submenu_calibrate = {
     '': {
-      title: "Calibrate"
+      title: "Calibrate",
+      back: function() {
+        E.showMenu(mainmenu);
+      },
     },
-    '< Back': function() {
-      E.showMenu(mainmenu);
+    'Autodetect': {
+      value: !!settings.autoCalibration,
+      onchange: v => {
+        writeSettings("autoCalibration", v);
+      }
     },
     'Offset': {
       min: -0.05,
@@ -75,25 +74,9 @@
       value: getInitialCalibrationOffset(),
       format: v => roundToDigits(v, stepsize).toFixed((""+stepsize).length - 2),
       onchange: v => {
-        print(typeof v);
-        systemsettings.batFullVoltage = v + full;
-        require("Storage").writeJSON("setting.json", systemsettings);
+        require("powermanager").setCalibration(v + full);
       }
     },
-    'Auto': function() {
-      var newVoltage = getCurrentVoltageDirect();
-      E.showAlert("Please charge fully before auto setting").then(() => {
-        E.showPrompt("Set current charge as full").then((r) => {
-          if (r) {
-            systemsettings.batFullVoltage = newVoltage;
-            require("Storage").writeJSON("setting.json", systemsettings);
-            //reset value shown in menu to the newly set one
-            submenu_calibrate.Offset.value = getInitialCalibrationOffset();
-            E.showMenu(mainmenu);
-          }
-        });
-      });
-    },
     'Clear': function() {
       E.showPrompt("Clear charging offset?").then((r) => {
         if (r) {
@@ -109,10 +92,10 @@
 
   var submenu_chargewarn = {
     '': {
-      title: "Charge warning"
-    },
-    '< Back': function() {
-      E.showMenu(mainmenu);
+      title: "Charge warning",
+      back: function() {
+        E.showMenu(mainmenu);
+      },
     },
     'Enabled': {
       value: !!settings.warnEnabled,
diff --git a/apps/primetimelato/ChangeLog b/apps/primetimelato/ChangeLog
new file mode 100644
index 000000000..46690e360
--- /dev/null
+++ b/apps/primetimelato/ChangeLog
@@ -0,0 +1,2 @@
+0.01: first release
+0.02: added option to buzz on prime, with settings
diff --git a/apps/primetimelato/README.md b/apps/primetimelato/README.md
new file mode 100644
index 000000000..924a6fae6
--- /dev/null
+++ b/apps/primetimelato/README.md
@@ -0,0 +1,19 @@
+# Prime Time Lato (clock)
+
+A watchface that displays time and its prime factors in the Lato font.
+For example when the time is 21:05, the prime factors are 5,421.
+Displays 'Prime Time!' when the time is a prime number.
+
+There is a settings option added in the Settings App.  If 'Buzz on
+Prime' is ticked then the buzzer will sound when 'Prime Time!' is
+detected.  Note the buzzer is limited to between 8am and 8pm so it
+should not go off when you want to sleep.
+
+
+![](screenshot.jpg)
+
+Written by: [Hugh Barney](https://github.com/hughbarney)
+
+Adapted from primetime by [Eve Bury](https://www.github.com/eveeeon)
+
+For support and discussion please post in the [Bangle JS Forum](http://forum.espruino.com/microcosms/1424/)
diff --git a/apps/primetimelato/app.js b/apps/primetimelato/app.js
new file mode 100644
index 000000000..817da7cda
--- /dev/null
+++ b/apps/primetimelato/app.js
@@ -0,0 +1,132 @@
+const h = g.getHeight();
+const w = g.getWidth();
+const SETTINGS_FILE = "primetimelato.json";
+let settings;
+
+Graphics.prototype.setFontLato = function(scale) {
+  // Actual height 41 (43 - 3)
+  this.setFontCustom(
+    E.toString(require('heatshrink').decompress(atob('ACEfAokB/AGEn+AAocDBgsfBgkB+A6Yg4FEgYgF/4FEv/gHIhAEh/+DwgYEgP/4AeJn4eF/hDEDwxrE/4eFKAgeFgJDERQ5DEXJI0Eh//IIZlB/4pDAoP/HgQYBAAIaBPARDDv4JBj5WBh5ZCv4CBPATPCeQcPwDYECAIMGPId4gEeSIYMHDIYMCExZAGh6ICn5QEK4RnGOgqBGaoKOECYSiDn78FAFgxFR4bcCKISOBgF+RwYCBTgQMCOIQMCj5eCBgIfDBgQfCBgSbDWoSHC/61CAwYMUAAYzCAAZNCBkYAfgYmBgJ7CTYsPTYQMBgJnBgYMCn4MBv64CBgMPXYSBBD4cfBgQIBgf3RwK7CvybCGQXPBgIfBGQIMBeoU/DoIMCF4IMDgP8BgQmBn8AEwb+BBgQIBIQJAC4BYBIgTNBDYIMBg///xRDn//OoIMBcYISBBgUHNATpCgjCngIEDFIM+AoV8TYKYCMIJQBAQLCCgZcBYQIJBX4UHCwSJBYQaSCMYQDCYQabEBgngW4V4WoQBBOgIMN+AoCEwY+BGYZfBYwQIBv+AG4X+j/8K4TCB+bECM4P+YgcD//B/67Ch/4h//K4SSBv5IBAAdAWqMYAokDD4t+AokfBgi0DAAX/Bgkf7wSE/IME/6PBCQfBBgcD/AME/ypCCQXABgYSBBgg+BBgYSBBgatBBggSBBgYSBBgcB/4ACZ4QGDZ4IMLFARAEAAJaEBjQAUhAnFMgIABuD5CVwWAboUDLwZ0DNYKLBM4IMBh50C55rBCoWACAIMC+EPFIN4EQQMBg4MSHgYzEBgIzBIAUAvhND+EH8DZCBAN/bIfwMQP/OgIMBLgalCBgKlDg//V4kfCQIAqWgYzC/gFDIAJgBZoS3CAwV//4TDh/+j5UCCQOAn4SD8DPCCQSbCCQR/BNAV/3i1CB4PzAYLCBgP8AYIMCv+HBgcP+AMDCQMHEwb2BYQLPDgYMBIAKoBOYLPCwDNBZ4UQBQPhJ4J0EDYJbCZ4R7EAoZiDSoaUDADBNBFQj5EKwQMEGAoMEOgQFCnAMEQIYFBgaOCBgTRBBgc/AYIMCaIQMCgb2CBgX/JQIMCDAQMCh/8JoYYBJoiNDBgIYDBgIYDBgPzUwkfUwisBOokfDAYMCQIq/ERwwAcn4pCgfwg42D//B/6hBCAP+KwYQBMQKbBgF//9+g5EBh4YB4CfC/EHDwK1Dn7PD8A0BgF4gEeAIUHBgQBBBi4mEGYpAEAIMP4BNELQpnGOgM/ZYaBFGQMPYos/JAIAuj4xEKgJrBfoX//hEE/4TDCQJSCCoN/gZfBjCBCj+AgaOCAIiKBg4OCgKKBvgbCWYMDToK1CgE8JIQMC4ZCBBgU4HYTNCz4JBEwV7KoQzCUIYvBLYZNBn60CLQPfCQcDM4LHCEALHDZ4TaCCYaODHYK8fh6FDEwKSCF4Uf4COCBgJsBn4MDDIJPDVgYAZA='))),
+    46,
+    atob("CxMeHh4eHh4eHh4eDQ=="),
+    52+(scale<<8)+(1<<16)
+  );
+  return this;
+};
+
+Graphics.prototype.setFontLatoSmall = function(scale) {
+  // Actual height 21 (20 - 0)
+  this.setFontCustom(
+    E.toString(require('heatshrink').decompress(atob('AB0B/+ch/88EAgQPHg/AgE+A4cPwEAvwTHoEAscQgc/wE//EP/0Au0wgEz/ED/+A//gg9jgEDiEAAIIACgcBwF+h0H+EwmPBwOf/4AB8Ng4cDg84hkfwFAvA/EgZfBneAwOEjkMkPAuccgPzwAbBCQJeBuZBBKYNxxkOhkgsFjgUD+B5BNox+Bgf4g/Y8F/wcDjkYjFw4HB40Gg8wjkfQgJLBj6bB84fBjCQDU5AAFj/wg//+A1B9xEGhkAg18gPx//+bgJmBAAckAQOgDQa+BgI2BjQ0HsBoBJogSBZgX/DQIrBG4IUFAAs7CQKVBFJ0GB4kEAQNwYQYACEIKaBRYX4RoQGCCoIADMwMf/kB8HwdQPA4EGGAMwmEBwPAjkHwfACgMAv4iBAAxICuEMSQNguEDgf//Ef/5ZEmCDDAAkBgHgnEOgfA8Eeg0B2EwjOBweMh04sE/wZlBfYomCjkPEIM8VwNgsI+BhkYjHg4HnFoPv8EOQYIAFQwMHJAN8gEOW4PjAgMYQwUPcIN/cI4ACkCNBgP8hkPsFgsY+BHoMYD4PHjkGj/gkAxBAAoHBh/wgPxV4J8B7EwnnBwPGhkMnHggLUBg/gXI6oDCgLiBsE+gb0BjD7B74bDni1CAAk8gP3+EP/ZTBfYMYfYI+Bw0Mh148E+HwPj+A6DAAIpBj6HB/0EhkwPwPPgcE+EYt4ZB8EDDwMP4EAiAeDgUCgE4nEB4INBAAcBKQMcjuA8BhBAAh2BgY5BjwUBJAMMDwPGJoMYf4ImFAAoUCswhBEgMZEgPMDgL7BmYpBzAUDABnBwEDhhTBFIiIBh4PBuDKCCwR6BgITBDAKSB88Dh84jAKB/geBHAQcBj/wgPhcIMDgOBhkYn0w4exw0wwkhxkhxHBhl/hFj7BKBo0Mg0wnnjgF+LIkEbQc/gEH8EBHgM/sEH4bGBjEB/HAgf2JIKvBCgICBJ4MOaYv//kP//gsHDga/BjEw4HBw0GjkwnHhwH9/kH54hBnggDkB1D//AjydBPIIyBFIMDgcAFIILB4EGg0AFIILBCgLLHj4kB//+CgUwTwOAhi8DFIccZANghkD4Pgh/+D4L7IFIpuBmHBwOGFIMwsFhwcDaAJTCwECSRasBJgIjBgDXBgxMBEYMBwUAhAdEn+Ag//gF8vkDwHgPoJoIXgnhw0Dh/gjF/Mo8D//4NASDBIgICBPQJLBIgJZBwEAF4IwBgB9BIYPwE44pHBYoiHg4EBvAUBwAZBExMAv//FIQbCKYU8AgJABnPggeHwE4jyKBnC8CgC8LEQZ2PNBA4BgIgCDYMHCwMfJIYNCnwMBB4N4gEPBYY+CNAJyHU4U/QoIxCFwQNBjwCBnACBHgpoGAAz4BB4PAj5OB8E4hwDBsEDPwMYSQXAgx+BmE4TwIUBPAOAh65Bj5wBAAxTBwI+BhgiBsHAgYiBjChB4OAg8cDwJVBvgfHB4KCBvl8HQmBwEMXoNgKY/ghwUBPAP+SoPhwF8ghOH//+U4UwsEBwbnBKYXwgcPRQPngF+jzMBuAbBOYnAn8GgP4nEc8HA4aSBjkwmHhwPDzkGNwMgNwYwBDoMAQgKoBcYIqBfYhoBChQAFv/4YIPwewIgBboIoFSIJnBgEHAgPwj6nBDIQVEAwMH8BBBAQQ6BdIUeCAc/BgR4BHwI6BCYJTCEIQCBj4CBh4CCE4QVBDQP+dYYLBnwcBBIMBAQoUBg/Agf8KwIZBHYIoBNIQSBfYUcIQP4nkB8YZBDwMPLoK5BJIXz8EPg+A8EeU4KmCXgYFBngCBDwL2BLAJGBMwIHCB4JKBgF4BwIWBiBGCgQpBuEwg+BwF8hkPsFg+cDj0YjPg4HcgwhBmBXBCgJnDAALmB/+f//8JYMkIoIMCHYJqCAQUfBYICCUYU4EgkIJINAgETFIgPEgAhBHoJtEgb3DQoacBSAQAGkBLDGYMAGYMCQ4gYBgiLCAQMYAQIzBDQQAFsbZBMYMxzEBxlAhlmgFikCIBwEPLowABn//4P/aIMwCgJUBjBpB4FgWYISBMIP/OYYAEg4MBv/Ag4UBmEYEAPAhjlBsEwgLyCAAcBIYMf+EB4IUFHwcIj//8C5BLA4cB4EBBgMdjkA5BTBocAmQ+ByBGBx0AjlgDISoCfwJ4BQ4P4jKwB5kAgwTDsEP3+A//mg03mEwzOBwnMU4Ngv8zgfh+EYPAKEEFIIuB4BnBEoMAPgTcEHgMAh/4NQ8gBwOf/kcPoKWEyEAhvP//uv7UBQgiSETgJ4BAgLJBnPAgeHcweAGALDGfYMP/4XBAAxmBSoP4FYQgBNAsPcIN/8CBCmBADCgV/dwP/BAIpIYgQpHLoIpCdwT7Mj08EIL7BDoMwfYOA4EOhwxBT4KUFHwXwZ4ITB4EMaQNgmEDDoMYH4LeBgZtBgZdFHwscCgI+HwOAUoPgv5eIPp8QCgcD4YUBFIOYKYPGgFjKYMfwEIvAUCgi8Dj5qBcwJoGGQQADn7JCH4J9BbRHAKYoAEuBLBVIMHAQMPEIMPBoIUBJYIMCvhoDAAQZFDgkeBQUBDwIGBEYUBXgIKCgYUBNAM/wA+CSQi8CnE4gH3B4PwFIINBIIMPSQPh8AUCAAMBLQR3Bn4KBn4SBv4fDDgJlBgI2BTw0AU4XBFIMfgEx7EB3nAh+GgF4XgOBF4JRCGAP/8f+/+YbAINBkArGbwIQB/5BBAArwBgLSBhP/v/H/6QBBAIACjhrDKwVgdAwHBRQThBgIbD'))),
+    32,
+    atob("BAcIDAwRDwUGBggMBAcECAwMDAwMDAwMDAwFBQwMDAgRDg4OEAwMDxAGCQ4LExARDREOCwwPDhUODQ0GCAYMCAYLDAoMCwcLDAUFCwURDAwMDAgJCAwLEAsLCgYGBgwA"),
+    21+(scale<<8)+(1<<16)
+  );
+  return this;
+};
+
+function loadSettings() {
+  settings = require("Storage").readJSON(SETTINGS_FILE,1)||{};
+  settings.buzz_on_prime = (settings.buzz_on_prime === undefined ? false : settings.buzz_on_prime);
+}
+	
+// creates a list of prime factors of n and outputs them as a string, if n is prime outputs "Prime Time!"
+function primeFactors(n) {
+  const factors = [];
+  let divisor = 2;
+
+  while (n >= 2) {
+    if (n % divisor == 0) {
+      factors.push(divisor);
+      n = n / divisor;
+    } else {
+      divisor++;
+    }
+  }
+  if (factors.length === 1) {
+    return "Prime Time!";
+  }
+  else
+    return factors.toString();
+}
+
+
+// converts time HR:MIN to integer HRMIN e.g. 15:35 => 1535
+function timeToInt(t) {
+    var arr = t.split(':');
+    var intTime = parseInt(arr[0])*100+parseInt(arr[1]);
+
+    return intTime;
+}
+
+function draw() {
+  var date = new Date();
+  var timeStr = require("locale").time(date,1);
+  var intTime = timeToInt(timeStr);
+  var primeStr = primeFactors(intTime);
+
+  g.reset();
+  g.setColor(0,0,0);
+  g.fillRect(Bangle.appRect);
+
+  g.setFontLato();
+  g.setFontAlign(0, 0);
+  g.setColor(100,100,100);
+  g.drawString(timeStr, w/2, h/2);
+
+  g.setFontLatoSmall();
+  g.drawString(primeStr, w/2, 3*h/4);
+
+  // Buzz if Prime Time and between 8am and 8pm
+  if (settings.buzz_on_prime && primeStr == "Prime Time!" && intTime >= 800 && intTime <= 2000)
+      buzzer(2);
+  queueDraw();
+}
+
+// timeout for multi-buzzer
+var buzzTimeout;
+
+// n buzzes
+function buzzer(n) {
+  if (n-- < 1) return;
+  Bangle.buzz(250);
+  
+  if (buzzTimeout) clearTimeout(buzzTimeout);
+  buzzTimeout = setTimeout(function() {
+    buzzTimeout = undefined;
+    buzzer(n);
+  }, 500);
+}
+	
+// timeout used to update every minute
+var drawTimeout;
+
+// schedule a draw for the next minute
+function queueDraw() {
+  if (drawTimeout) clearTimeout(drawTimeout);
+  drawTimeout = setTimeout(function() {
+    drawTimeout = undefined;
+    draw();
+  }, 60000 - (Date.now() % 60000));
+}
+
+// Stop updates when LCD is off, restart when on
+Bangle.on('lcdPower',on=>{
+  if (on) {
+    draw(); // draw immediately, queue redraw
+  } else { // stop draw timer
+    if (drawTimeout) clearTimeout(drawTimeout);
+    drawTimeout = undefined;
+  }
+});
+
+
+loadSettings();
+g.clear();
+// Show launcher when middle button pressed
+Bangle.setUI("clock");
+
+// Load widgets
+Bangle.loadWidgets();
+Bangle.drawWidgets();
+draw();
diff --git a/apps/primetimelato/app.png b/apps/primetimelato/app.png
new file mode 100644
index 000000000..2a84c62a0
Binary files /dev/null and b/apps/primetimelato/app.png differ
diff --git a/apps/primetimelato/icon.js b/apps/primetimelato/icon.js
new file mode 100644
index 000000000..06f93e2ef
--- /dev/null
+++ b/apps/primetimelato/icon.js
@@ -0,0 +1 @@
+require("heatshrink").decompress(atob("mEwwIdah/wAof//4ECgYFB4AFBg4FB8AFBj/wh/4AoM/wEB/gFBvwCEBAU/AQP4gfAj8AgPwAoMPwED8AFBg/AAYIBDA4ngg4TB4EBApkPKgJSBJQIFTMgIFCJIIFDKoIFEvgFBGoMAnw7DP4IFEh+BAoItBg+DNIQwBMIaeCKoKxCPoIzCEgKVHUIqtFXIrFFaIrdFdIwAV"))
diff --git a/apps/primetimelato/metadata.json b/apps/primetimelato/metadata.json
new file mode 100644
index 000000000..6b292c380
--- /dev/null
+++ b/apps/primetimelato/metadata.json
@@ -0,0 +1,18 @@
+{ "id": "primetimelato",
+  "name": "Prime Time Lato Clock",
+  "version": "0.02",  
+  "type": "clock",
+  "description": "A clock that tells you the primes of the time in the Lato font",
+  "icon": "app.png",
+  "screenshots": [{"url":"screenshot.png"}],  
+  "tags": "clock",
+  "supports": ["BANGLEJS2"],  
+  "readme": "README.md",
+  "storage": [
+    {"name":"primetimelato.app.js","url":"app.js"},
+    {"name":"primetimelato.img","url":"icon.js","evaluate":true},
+    {"name":"primetimelato.settings.js","url":"settings.js"}
+
+  ],
+  "data": [{"name":"primetimelato.json"}]
+}
diff --git a/apps/primetimelato/screenshot.png b/apps/primetimelato/screenshot.png
new file mode 100644
index 000000000..7f6e7cc0d
Binary files /dev/null and b/apps/primetimelato/screenshot.png differ
diff --git a/apps/primetimelato/settings.js b/apps/primetimelato/settings.js
new file mode 100644
index 000000000..5550055eb
--- /dev/null
+++ b/apps/primetimelato/settings.js
@@ -0,0 +1,34 @@
+(function(back) {
+  const SETTINGS_FILE = "primetimelato.json";
+
+  // initialize with default settings...
+  let s = {
+    'buzz_on_prime': true
+  }
+
+  // ...and overwrite them with any saved values
+  // This way saved values are preserved if a new version adds more settings
+  const storage = require('Storage')
+  let settings = storage.readJSON(SETTINGS_FILE, 1) || {}
+  const saved = settings || {}
+  for (const key in saved) {
+    s[key] = saved[key]
+  }
+
+  function save() {
+      settings = s;
+      storage.write(SETTINGS_FILE, settings);
+  }
+
+  E.showMenu({
+    '': { 'title': 'Prime Time Lato' },
+    '< Back': back,
+    'Buzz on Prime': {
+      value: !!s.buzz_on_prime,
+      onchange: v => {
+        s.buzz_on_prime = v;
+        save();
+      },
+    }
+  })
+})
diff --git a/apps/random/ChangeLog b/apps/random/ChangeLog
new file mode 100644
index 000000000..c819919ed
--- /dev/null
+++ b/apps/random/ChangeLog
@@ -0,0 +1,2 @@
+0.01: New app!
+0.02: Submitted to the app loader
\ No newline at end of file
diff --git a/apps/random/app.js b/apps/random/app.js
new file mode 100644
index 000000000..c3001a6d1
--- /dev/null
+++ b/apps/random/app.js
@@ -0,0 +1,205 @@
+let n = 1;
+let diceSides = 6;
+let replacement = false;
+let min = 1;
+let max = 10;
+
+Bangle.loadWidgets();
+Bangle.drawWidgets();
+
+function showCoinMenu() {
+  E.showMenu({
+    '': {
+      'title': 'Coin flip',
+      'back': showMainMenu
+    },
+    '# of coins': {
+      value: n,
+      step: 1,
+      min: 1,
+      onchange: value => n = value
+    },
+    'Go': () => {
+      let resultMenu = {
+        '': {
+          'title': 'Result',
+          'back': showCoinMenu
+        }
+      };
+      let heads = 0;
+      for (let i = 0; i < n; i++) {
+        let coin = Math.random() < 0.5;
+        if (coin) heads++;
+        resultMenu[`${i + 1}: ${coin ? 'Heads' : 'Tails'}`] = () => { };
+      }
+      let tails = n - heads;
+      resultMenu[`${heads} heads, ${Math.round(100 * heads / n)}%`] = () => { };
+      resultMenu[`${tails} tails, ${Math.round(100 * tails / n)}%`] = () => { };
+
+      E.showMenu(resultMenu);
+    }
+  });
+}
+
+
+function showDiceMenu() {
+  E.showMenu({
+    '': {
+      'title': 'Dice roll',
+      'back': showMainMenu
+    },
+    '# of dice': {
+      value: n,
+      step: 1,
+      min: 1,
+      onchange: value => n = value
+    },
+    '# of sides': {
+      value: diceSides,
+      step: 1,
+      min: 2,
+      onchange: value => diceSides = value
+    },
+    'Go': () => {
+      let resultMenu = {
+        '': {
+          'title': 'Result',
+          'back': showDiceMenu
+        }
+      };
+      let sum = 0;
+      let min = diceSides + 1;
+      let max = 0;
+      for (let i = 0; i < n; i++) {
+        let roll = Math.floor(Math.random() * diceSides + 1);
+        sum += roll;
+        if (roll < min) min = roll;
+        if (roll > max) max = roll;
+        resultMenu[`${i + 1}: ${roll}`] = () => { };
+      }
+      resultMenu[`Sum: ${sum}`] = () => { };
+      resultMenu[`Min: ${min}`] = () => { };
+      resultMenu[`Max: ${max}`] = () => { };
+      resultMenu[`Average: ${sum / n}`] = () => { };
+
+      E.showMenu(resultMenu);
+    }
+  });
+}
+
+
+function showCardMenu() {
+  E.showMenu({
+    '': {
+      'title': 'Card draw',
+      'back': showMainMenu
+    },
+    '# of cards': {
+      value: Math.min(52, n),
+      step: 1,
+      min: 1,
+      max: 52,
+      onchange: value => n = value
+    },
+    'Replacement': {
+      value: replacement,
+      onchange: value => {
+        replacement = value;
+        if (replacement && n > 52) n = 52;
+      }
+    },
+    'Go': () => {
+      n = Math.min(n, 52);
+      SUITS = ['Spades', 'Diamonds', 'Clubs', 'Hearts'];
+      RANKS = ['Ace', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'Jack', 'Queen', 'King'];
+      class Card {
+        constructor(suit, rank) {
+          this.suit = suit;
+          this.rank = rank;
+        }
+
+        //Can't use == to check equality, so using Java-inspired .equals()
+        equals(other) {
+          return this.suit == other.suit && this.rank == other.rank;
+        }
+      }
+
+      let resultMenu = {
+        '': {
+          'title': 'Result',
+          'back': showCardMenu
+        }
+      };
+      let cards = [];
+      for (let i = 0; i < n; i++) {
+        let newCard;
+        while (true) {
+          newCard = new Card(
+            SUITS[Math.floor(Math.random() * SUITS.length)],
+            RANKS[Math.floor(Math.random() * RANKS.length)]);
+
+          if (replacement) break; //If we are doing replacement, skip the check for duplicates and stop looping
+
+          if (!cards.map(card => card.equals(newCard)).includes(true)) break; //If there are no duplicates found, stop looping
+        }
+
+        cards.push(newCard);
+        resultMenu[`${newCard.rank} of ${newCard.suit}`] = () => { };
+      }
+
+      E.showMenu(resultMenu);
+    }
+  });
+}
+
+function showNumberMenu() {
+  E.showMenu({
+    '': {
+      'title': 'Number choice',
+      'back': showMainMenu
+    },
+    'Minimum': {
+      value: min,
+      step: 1,
+      onchange: value => min = value
+    },
+    'Maximum': {
+      value: max,
+      step: 1,
+      onchange: value => max = value
+    },
+    '# of choices': {
+      value: n,
+      min: 1,
+      step: 1,
+      onchange: value => n = value
+    },
+    'Go': () => {
+      let resultMenu = {
+        '': {
+          'title': 'Result',
+          'back': showNumberMenu
+        }
+      };
+      for (let i = 0; i < n; i++) {
+        let value = Math.floor(min + Math.random() * (max - min + 1));
+        resultMenu[`${i + 1}: ${value}`] = () => { };
+      }
+      E.showMenu(resultMenu);
+    }
+  });
+}
+
+function showMainMenu() {
+  E.showMenu({
+    '': {
+      'title': 'Random'
+    },
+    'Coin': showCoinMenu,
+    'Dice': showDiceMenu,
+    'Card': showCardMenu,
+    'Number': showNumberMenu
+  });
+}
+
+showMainMenu();
\ No newline at end of file
diff --git a/apps/random/icon.js b/apps/random/icon.js
new file mode 100644
index 000000000..de84a0893
--- /dev/null
+++ b/apps/random/icon.js
@@ -0,0 +1 @@
+require("heatshrink").decompress(atob("mEwwIEBgf///AAoMHAoPgAoMPAoPwAoMfAoP4AoM/AoP8AoN/AoP+AoIEBAAMAgIbBD4OAAoPgFYIFC4A3BAoQCFEAQFBEwV/AoIyCn+ALYYFFCIIFDDoIECFIQFCGoQFCIIQFCJoQFCNoIuEHwQuCHwQuCQYQuCR4QuCTYQuGAoIcDg4oEg4oEg6mCAoQuDAoIuDAFQvFAsIA=="))
\ No newline at end of file
diff --git a/apps/random/icon.png b/apps/random/icon.png
new file mode 100644
index 000000000..e4af7d7a1
Binary files /dev/null and b/apps/random/icon.png differ
diff --git a/apps/random/metadata.json b/apps/random/metadata.json
new file mode 100644
index 000000000..1af4bc8ac
--- /dev/null
+++ b/apps/random/metadata.json
@@ -0,0 +1,24 @@
+{
+  "id": "random",
+  "name": "Random",
+  "version": "0.02",
+  "description": "Flip coins, roll dice, draw a card, or choose random numbers",
+  "icon": "icon.png",
+  "tags": "tool",
+  "supports": [
+    "BANGLEJS",
+    "BANGLEJS2"
+  ],
+  "allow_emulator": true,
+  "storage": [
+    {
+      "name": "random.app.js",
+      "url": "app.js"
+    },
+    {
+      "name": "random.img",
+      "url": "icon.js",
+      "evaluate": true
+    }
+  ]
+}
\ No newline at end of file
diff --git a/apps/rebble/ChangeLog b/apps/rebble/ChangeLog
index c392cc74b..21dd44e77 100644
--- a/apps/rebble/ChangeLog
+++ b/apps/rebble/ChangeLog
@@ -8,4 +8,5 @@
 0.08: removed unused font, fix autocycle,  imported suncalc and trimmed, removed pedometer dependency, "tap to cycle" setting
 0.09: fix battery icon size
 0.10: Tell clock widgets to hide.
-0.11: fix issue https://github.com/espruino/BangleApps/issues/2128 (#2128) ( settings undefined )
\ No newline at end of file
+0.11: fix issue https://github.com/espruino/BangleApps/issues/2128 (#2128) ( settings undefined )
+0.12: implemented widget_utils 
\ No newline at end of file
diff --git a/apps/rebble/metadata.json b/apps/rebble/metadata.json
index 9134ccd23..dfc0703c0 100644
--- a/apps/rebble/metadata.json
+++ b/apps/rebble/metadata.json
@@ -2,7 +2,7 @@
   "id": "rebble",
   "name": "Rebble Clock",
   "shortName": "Rebble",
-  "version": "0.11",
+  "version": "0.12",
   "description": "A Pebble style clock, with configurable background, three sidebars including steps, day, date, sunrise, sunset, long live the rebellion",
   "readme": "README.md",
   "icon": "rebble.png",
diff --git a/apps/rebble/rebble.app.js b/apps/rebble/rebble.app.js
index 2ddd3a9b9..82e4a62d7 100644
--- a/apps/rebble/rebble.app.js
+++ b/apps/rebble/rebble.app.js
@@ -309,16 +309,8 @@ else{
 }
 
 
-g.clear();
 Bangle.loadWidgets();
-/*
- * we are not drawing the widgets as we are taking over the whole screen
- * so we will blank out the draw() functions of each widget and change the
- * area to the top bar doesn't get cleared.
- */
-for (let wd of WIDGETS) {wd.draw=()=>{};wd.area="";}
-
-
+require("widget_utils").hide();
 
 
 
diff --git a/apps/rinkulainen/ChangeLog b/apps/rinkulainen/ChangeLog
new file mode 100644
index 000000000..5560f00bc
--- /dev/null
+++ b/apps/rinkulainen/ChangeLog
@@ -0,0 +1 @@
+0.01: New App!
diff --git a/apps/rinkulainen/README.md b/apps/rinkulainen/README.md
new file mode 100644
index 000000000..4b32ada0a
--- /dev/null
+++ b/apps/rinkulainen/README.md
@@ -0,0 +1,14 @@
+# Rinkulainen
+
+By Jukio Kallio
+
+A Minimal & stylish watch face, with rings or disks for hours and minutes. Date underneath. With easy to mod source code for making your own themes. Some example themes included.
+
+![](screenshot2.png) 
+Default Colorful theme
+
+![](screenshot1.png) 
+Grayscale theme
+
+![](screenshot3.png) 
+Maze theme
diff --git a/apps/rinkulainen/app-icon.js b/apps/rinkulainen/app-icon.js
new file mode 100644
index 000000000..0618f7891
--- /dev/null
+++ b/apps/rinkulainen/app-icon.js
@@ -0,0 +1 @@
+require("heatshrink").decompress(atob("mEwwkEogA0/4AKCpNPCxYAB+gtTGJQuOGBAWPGAwuQGAwXG+chiMRiU/C6HyiDpDgMvC5ItFCoYADGIoXIFoIqDGgUBC5nxB4IoE+YYBj4XLBwJxGJ4IwEC4wuBiYEBmUhiUjAoMxGAgXGmAuCDYIACCYIwBgYXJBYSQGD4IjBC5HyE4QOBgU/+cgEAQ3BTAQXFBQImBN4p/BHARgCC4swCYIaBT4gGDiBgCC4syQ4JVENIsggTvKBgYHG+BRCC5KdDWIYXOiEPC4oUCC8hHUmTJBO44XMCgSnH+SnLa5IABfILXJCgINBgA9CAAnzEIYXF+QKCJAMCn/zkQXCEgJtBR479CEwIADCQRpEC4wLBJAInBAAQ3BD4KxDC4wTBiatCkMSkYFBmKAEa48QGAR1GP4gXHGAMBDAnzEAKvEC44wCgJzC+QGCBwgXIRwoACJ4oXDp4JEFQQACGgYAC+gXJGIMhiMRiR9GC5YALC4hgFABgWEGCIuFGCIWGGB4uHGJwVJAFY"))
diff --git a/apps/rinkulainen/app.js b/apps/rinkulainen/app.js
new file mode 100644
index 000000000..b487c9a0d
--- /dev/null
+++ b/apps/rinkulainen/app.js
@@ -0,0 +1,146 @@
+// Rinkulainen
+//
+// Bangle.js 2 watch face
+// by Jukio Kallio
+// www.jukiokallio.com
+
+// settings
+const watch = { 
+  theme: "default", 
+  x:0, y:0, w:0, h:0, 
+  color:"#000000", // change background color
+  finland:true, // change if you want Finnish style date, or US style
+  
+    // default theme "colorful"
+    hour: { size:60, weight:8, color:"#00FFFF", cursor:10 },
+    minute: { size:40, weight:16, color:"#FFFF00", cursor:6 },
+    second: { on: false, cursor:2 }, // if on, uses a lot more battery
+    date: { font:"6x8", size:1, y:15, color:"#FFFF00" }
+};
+
+// more themes
+if (watch.theme == "grayscale") {
+  watch.hour = { size:60, weight:20, color:"#999999", cursor:8 };
+  watch.minute = { size:40, weight:20, color:"#dddddd", cursor:8 };
+  watch.second = { on: false, cursor:2 }; // if on, uses a lot more battery
+  watch.date = { font:"6x8", size:1, y:15, color:"#ffffff" };
+} else if (watch.theme == "maze") {
+  watch.hour = { size:50, weight:7, color:"#ffffff", cursor:6 };
+  watch.minute = { size:30, weight:7, color:"#ffffff", cursor:6 };
+  watch.second = { on: false, cursor:2 }; // if on, uses a lot more battery
+  watch.date = { font:"6x8", size:1, y:15, color:"#ffffff" };
+} else if (watch.theme == "disks") {
+  watch.hour = { size:72, weight:30, color:"#00ff66", cursor:4 };
+  watch.minute = { size:36, weight:32, color:"#0066ff", cursor:4 };
+  watch.second = { on: false, cursor:2 }; // if on, uses a lot more battery
+  watch.date = { font:"6x8", size:1, y:10, color:"#ffffff" };
+}
+
+// set some additional settings
+watch.w = g.getWidth(); // size of the background
+watch.h = g.getHeight();
+watch.x = watch.w * 0.5; // position of the circles
+watch.y = watch.h * 0.46;
+watch.date.y = watch.date.y + watch.y + watch.hour.size; // final position of the date
+
+const dateWeekday = { 0: "Sunday", 1: "Monday", 2: "Tuesday", 3: "Wednesday", 4:"Thursday", 5:"Friday", 6:"Saturday" }; // weekdays
+
+var wait = 60000; // wait time, normally a minute
+if (watch.second.on) wait = 1000; // a second if seconds are used
+
+
+// timeout used to update every minute
+var drawTimeout;
+
+// schedule a draw for the next minute
+function queueDraw() {
+  if (drawTimeout) clearTimeout(drawTimeout);
+  drawTimeout = setTimeout(function() {
+    drawTimeout = undefined;
+    draw();
+  }, wait - (Date.now() % wait));
+}
+
+
+// main function
+function draw() {
+  // make date object
+  var date = new Date();
+  
+  // work out the date string
+  var dateDay = date.getDate();
+  var dateMonth = date.getMonth() + 1;
+  var dateYear = date.getFullYear();
+  var dateStr = dateWeekday[date.getDay()] + " " + dateMonth + "." + dateDay + "." + dateYear;
+  if (watch.finland) dateStr = dateWeekday[date.getDay()] + " " + dateDay + "." + dateMonth + "." + dateYear; // the true way of showing date
+
+  // Reset the state of the graphics library
+  g.reset();
+  
+  // Clear the area where we want to draw the time
+  g.setColor(watch.color);
+  g.fillRect(0, 0, watch.w, watch.h);
+  
+  // variables for vertex transformation
+  var tver, tobj, tran;
+  
+  // draw hour circle
+  g.setColor(watch.hour.color).fillCircle(watch.x, watch.y, watch.hour.size);
+  g.setColor(watch.color).fillCircle(watch.x, watch.y, watch.hour.size - watch.hour.weight);
+  // draw hour line
+  g.setColor(watch.color);
+  var thour = (date.getHours() / 12) * (Math.PI * 2);
+  tver = [-watch.hour.cursor, 0, watch.hour.cursor, 0, watch.hour.cursor, -watch.hour.size*1.05, -watch.hour.cursor, -watch.hour.size*1.05];
+  tobj = { x:watch.x, y:watch.y, scale:1, rotate:thour };
+  tran = g.transformVertices(tver, tobj);
+  g.fillPoly(tran);
+  
+  // draw minute circle
+  g.setColor(watch.minute.color).fillCircle(watch.x, watch.y, watch.minute.size);
+  g.setColor(watch.color).fillCircle(watch.x, watch.y, watch.minute.size - watch.minute.weight);
+  // draw minute line
+  g.setColor(watch.color);
+  var tmin = (date.getMinutes() / 60) * (Math.PI * 2);
+  tver = [-watch.minute.cursor, 0, watch.minute.cursor, 0, watch.minute.cursor, -watch.minute.size*1.05, -watch.minute.cursor, -watch.minute.size*1.05];
+  tobj = { x:watch.x, y:watch.y, scale:1, rotate:tmin };
+  tran = g.transformVertices(tver, tobj);
+  g.fillPoly(tran);
+  
+  // draw seconds line, if the feature is on
+  if (watch.second.on) {
+    g.setColor(watch.color);
+    var tsec = (date.getSeconds() / 60) * (Math.PI * 2);
+    tver = [-watch.second.cursor, 0, watch.second.cursor, 0, watch.second.cursor, -watch.second.size*1.045, -watch.second.cursor, -watch.second.size*1.045];
+    tobj = { x:watch.x, y:watch.y, scale:1, rotate:tsec };
+    tran = g.transformVertices(tver, tobj);
+    g.fillPoly(tran);
+  }
+  
+  // draw date
+  g.setFontAlign(0,0).setFont(watch.date.font, 1).setColor(watch.date.color);
+  g.drawString(dateStr, watch.x, watch.date.y + watch.date.size + 2);
+  
+  // queue draw
+  queueDraw();
+}
+
+
+// Clear the screen once, at startup
+g.clear();
+// draw immediately at first
+draw();
+
+
+// Stop updates when LCD is off, restart when on
+Bangle.on('lcdPower',on=>{
+  if (on) {
+    draw(); // draw immediately, queue redraw
+  } else { // stop draw timer
+    if (drawTimeout) clearTimeout(drawTimeout);
+    drawTimeout = undefined;
+  }
+});
+
+
+// Show launcher when middle button pressed
+Bangle.setUI("clock");
diff --git a/apps/rinkulainen/app.png b/apps/rinkulainen/app.png
new file mode 100644
index 000000000..50782c48d
Binary files /dev/null and b/apps/rinkulainen/app.png differ
diff --git a/apps/rinkulainen/metadata.json b/apps/rinkulainen/metadata.json
new file mode 100644
index 000000000..f0a51af87
--- /dev/null
+++ b/apps/rinkulainen/metadata.json
@@ -0,0 +1,16 @@
+{ "id": "rinkulainen",
+  "name": "Rinkulainen - Minimal & Stylish watch face",
+  "shortName":"Rinkulainen",
+  "version":"0.01",
+  "description": "A minimal watch face, with rings/disks for hours and minutes. Date underneath. With easy to mod source code for making your own themes. Some example themes included.",
+  "icon": "app.png",
+  "screenshots": [{"url":"screenshot2.png"}, {"url":"screenshot1.png"}, {"url":"screenshot3.png"}],
+  "type": "clock",
+  "tags": "clock",
+  "supports" : ["BANGLEJS","BANGLEJS2"],  
+  "readme": "README.md",
+  "storage": [
+    {"name":"rinkulainen.app.js","url":"app.js"},
+    {"name":"rinkulainen.img","url":"app-icon.js","evaluate":true}
+  ]
+}
diff --git a/apps/rinkulainen/screenshot1.png b/apps/rinkulainen/screenshot1.png
new file mode 100644
index 000000000..f897c5a4e
Binary files /dev/null and b/apps/rinkulainen/screenshot1.png differ
diff --git a/apps/rinkulainen/screenshot2.png b/apps/rinkulainen/screenshot2.png
new file mode 100644
index 000000000..354618167
Binary files /dev/null and b/apps/rinkulainen/screenshot2.png differ
diff --git a/apps/rinkulainen/screenshot3.png b/apps/rinkulainen/screenshot3.png
new file mode 100644
index 000000000..ef1385288
Binary files /dev/null and b/apps/rinkulainen/screenshot3.png differ
diff --git a/apps/rpnsci/ChangeLog b/apps/rpnsci/ChangeLog
new file mode 100644
index 000000000..35ba8b130
--- /dev/null
+++ b/apps/rpnsci/ChangeLog
@@ -0,0 +1,3 @@
+0.01: New app!
+0.02: Bug fixes
+0.03: Submitted to the app loader
\ No newline at end of file
diff --git a/apps/rpnsci/README.md b/apps/rpnsci/README.md
new file mode 100644
index 000000000..437985c7a
--- /dev/null
+++ b/apps/rpnsci/README.md
@@ -0,0 +1,38 @@
+# Scientific calculator
+
+This is a reverse polish notation scientific calculator with memory function.
+
+## General operation
+
+In order to fit all of the functions, the calculator only displays 12 keys at a time. The top right of these keys is always a mode button. This will take you into number mode if you are in operation mode, and operation mode if you are in any other mode. The calculator starts out in number mode, where you can enter numbers. Operation mode lets you perform basic operations and RPN stack manipulation. In operation mode, you can press "Sci" to switch to Scientific mode. This provides trigonometric, logarithmic, and memory operations, as well as the constants e and pi.
+
+In any mode, the calculator also accepts swipes. A swipe up or down will function as the enter key (see below), and a swipe left will delete the last character if entry has not been terminated (see below) or function as the clear key if it has.
+
+The calculator will vibrate when performing an operation. If the operation is invalid, it will do a long vibration.
+
+## Reverse polish notation
+
+To save keystrokes and avoid the need for parentheses keys while still allowing you to control the order of operations, this calculator uses Reverse Polish Notation (RPN). There is a stack of 4 registers: x (the displayed value), y, z, and t (top). Pressing Enter will lift the stack. The value of z will be copied to t, y to z, and x to y. (The old value of t is lost.) This also terminates input, making the next numerical key press clear the value in x before typing its value. This enables you to enter a value into the stack multiple times by pressing Enter multiple times.
+
+Performing an operation will also terminate entry, and can either simply replace the value of x (if it is a one-number operation), or drop the stack (if it is a two number operation). Dropping the stack causes the existing values of x and y to be lost, replacing x with the result of the operation, y with the old value of z, and z with the old value of t. t remains the same.
+
+Effectively, to do an operation, you type the first operand, press Enter, and then type the second operand. If you want to do multiple operations, start with the one that you want to do first, and then continue operating on the result without pressing enter. For example, 3 Enter 2 Times 1 Plus computes (3\*2) + 1. 3 Enter 1 Plus 2 Times computes (3+1) \* 2. If you wish to compute something independently, simply press enter before starting the independent operation. For example, to compute (3 \* 2) + (4 \* 5), first compute 3 \* 2. Then press enter and compute 4 \* 5. You will have 6 in the y register and 20 in the x register. Press Plus to add them.
+
+You can also rotate the stack down with the Rot key. x gets set to the value of y, y gets set to the value of z, z gets set to the value of t, and t gets set to the old value of x. And you can swap x and y with Swp. I find this to be most handy when I want to subtract the result of an operation from another value, but I forget to enter another value first. For example, 20 - (2 \* 3) should usually be computed as 20 Enter 2 Enter 3 Times Minus. But if you compute 2 \* 3 first, you can enter 20, swap the values, and then subtract. (I do this more often than I would like to admit.)
+
+## Memory
+The calculator has 10 variables that you can store values in. In Scientific mode, press Sto to store the value of the x register in one of the values (which you then choose by pressing a number), or Rcl to read a value (which you choose by pressing a number) into the x register. These values are preserved when the calculator is closed.
+
+## Clearing
+
+A swipe left will delete one character, unless the number is already zero in which case it will emulate a press of the clear button (Clr). The clear button will set the value of x to 0 if it is not zero. If x=0, y, z, and t will be cleared to zero. And if they are already zero, pressing Clear again will clear the memory.
+
+## Limitations
+
+* This calculator uses Javascript's floating point numbers. These are fast, space efficient, and less complicated to code for (producing a smaller app), but they sacrifice some precision. You might see stuff like 0.1 + 0.2 adding to 0.30000000000000004, or the sine of pi being a very low value but not quite zero.
+
+* This calculator performs trigonometric operations in radians. If you wish to convert degrees to radians, multiply by (pi/180). If you wish to convert radians to degrees, multiply by (180 / pi).
+
+* This calculator performs logarithms in base 10. If you would like to perform logarithms in another base, divide the log of the number by the log of the base. For example, to compute log base 2 of 8, divide log(8) by log(2). (To get the natural log or ln, divide by log(e)).
+
+* This calculator considers 0^0 to be 1, a behavior inherited from Javascripts Math.pow() function. In reality, it is undefined because two mathematical rules give conflicting answers: anything^0 = 1, but 0^anything = 0.
\ No newline at end of file
diff --git a/apps/rpnsci/app.js b/apps/rpnsci/app.js
new file mode 100644
index 000000000..5c98770c4
--- /dev/null
+++ b/apps/rpnsci/app.js
@@ -0,0 +1,403 @@
+const MEMORY_FILE = "rpnsci.mem.json";
+const storage = require("Storage");
+
+class NumberButton {
+    constructor(number) {
+        this.label = '' + number;
+    }
+
+    onclick() {
+        if (entryTerminated) {
+            if (liftOnNumberPress) liftStack();
+            x = this.label;
+            entryTerminated = false;
+            liftOnNumberPress = false;
+        } else {
+            if (x == '0') x = this.label;
+            else x += this.label;
+        }
+        feedback(true);
+        updateDisplay();
+    }
+}
+
+let DecimalPointButton = {
+    label: '.',
+    onclick: () => {
+        if (entryTerminated) {
+            if (liftOnNumberPress) liftStack();
+            x = '0.';
+            entryTerminated = false;
+            liftOnNumberPress = false;
+            feedback(true);
+            updateDisplay();
+        } else if (!x.includes('.')) {
+            x += '.';
+            feedback(true);
+            updateDisplay();
+        } else {
+            feedback(false);
+        }
+    }
+};
+
+class ModeButton {
+    constructor(currentMode) {
+        if (currentMode == 'memstore' || currentMode == 'memrec') {
+            this.label = 'Exit';
+        } else if (currentMode == 'operation') {
+            this.label = 'Num';
+        } else {
+            this.label = 'Op';
+        }
+    }
+
+    onclick() {
+        if (mode == 'memstore' || mode == 'memrec') {
+            mode = 'operation';
+        } else if (mode == 'operation') {
+            mode = 'number';
+        } else {
+            mode = 'operation';
+        }
+        feedback(true);
+        drawButtons();
+    }
+}
+
+class OperationButton {
+    constructor(label) {
+        this.label = label;
+    }
+
+    onclick() {
+        if (this.label == '/' && parseFloat(x) == 0) {
+            feedback(false);
+            return;
+        }
+        let result = this.getResult();
+        x = '' + result;
+        y = z;
+        z = t;
+        entryTerminated = true;
+        liftOnNumberPress = true;
+        feedback(true);
+        updateDisplay();
+    }
+
+    getResult() {
+        let numX = parseFloat(x);
+        return {
+            '+': y + numX,
+            '-': y - numX,
+            '/': y / numX,
+            '*': y * numX,
+            '^': Math.pow(y, numX)
+        }[this.label];
+    }
+}
+
+class OneNumOpButton {
+    constructor(label) {
+        this.label = label;
+    }
+
+    onclick() {
+        result = {
+            '+-': '' + -parseFloat(x),
+            'Sin': '' + Math.sin(parseFloat(x)),
+            'Cos': '' + Math.cos(parseFloat(x)),
+            'Tan': '' + Math.tan(parseFloat(x)),
+            'Asin': '' + Math.asin(parseFloat(x)),
+            'Acos': '' + Math.acos(parseFloat(x)),
+            'Atan': '' + Math.atan(parseFloat(x)),
+            'Log': '' + (Math.log(parseFloat(x)) / Math.log(10))
+        }[this.label];
+        if (isNaN(result) || result == 'NaN') feedback(false);
+        else {
+            x = result;
+            entryTerminated = true;
+            liftOnNumberPress = true;
+            feedback(true);
+            updateDisplay();
+        }
+    }
+}
+
+let ClearButton = {
+    label: 'Clr',
+    onclick: () => {
+        if (x != '0') {
+            x = '0';
+            updateDisplay();
+        } else if (y != 0 || z != 0 || t != 0) {
+            y = 0;
+            z = 0;
+            t = 0;
+            E.showMessage('Registers cleared!');
+            setTimeout(() => {
+                drawButtons();
+                updateDisplay();
+            }, 250);
+        } else {
+            memory = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
+            storage.writeJSON(MEMORY_FILE, memory);
+            E.showMessage('Memory cleared!');
+            setTimeout(() => {
+                drawButtons();
+                updateDisplay();
+            }, 250);
+        }
+        entryTerminated = false;
+        liftOnNumberPress = false;
+        feedback(true);
+    }
+};
+
+let SwapButton = {
+    label: 'Swp',
+    onclick: () => {
+        oldX = x;
+        x = '' + y;
+        y = parseFloat(oldX);
+        entryTerminated = true;
+        liftOnNumberPress = true;
+        feedback(true);
+        updateDisplay();
+    }
+};
+
+let RotateButton = {
+    label: 'Rot',
+    onclick: () => {
+        oldX = x;
+        x = '' + y;
+        y = z;
+        z = t;
+        t = parseFloat(oldX);
+        entryTerminated = true;
+        liftOnNumberPress = true;
+        feedback(true);
+        updateDisplay();
+    }
+};
+
+let EnterButton = {
+    label: 'Ent',
+    onclick: () => {
+        liftStack();
+        entryTerminated = true;
+        liftOnNumberPress = false;
+        feedback(true);
+        updateDisplay();
+    }
+};
+
+let ScientificButton = {
+    label: 'Sci',
+    onclick: () => {
+        mode = 'scientific';
+        feedback(true);
+        drawButtons();
+    }
+};
+
+class ConstantButton {
+    constructor(label, value) {
+        this.label = label;
+        this.value = value;
+    }
+
+    onclick() {
+        if (entryTerminated && liftOnNumberPress) liftStack();
+        x = '' + this.value;
+        entryTerminated = true;
+        liftOnNumberPress = true;
+        feedback(true);
+        updateDisplay();
+    }
+}
+
+let MemStoreButton = {
+    label: 'Sto',
+    onclick: () => {
+        mode = 'memstore';
+        feedback(true);
+        drawButtons();
+    }
+};
+
+let MemRecallButton = {
+    label: 'Rec',
+    onclick: () => {
+        mode = 'memrec';
+        feedback(true);
+        drawButtons();
+    }
+};
+
+class MemStoreIn {
+    constructor(register) {
+        this.register = register;
+        this.label = '' + register;
+    }
+
+    onclick() {
+        memory[this.register] = parseFloat(x);
+        storage.writeJSON(MEMORY_FILE, memory);
+        mode = 'scientific';
+        entryTerminated = true;
+        liftOnNumberPress = true;
+        feedback(true);
+        drawButtons();
+    }
+}
+
+class MemRecFrom {
+    constructor(register) {
+        this.register = register;
+        this.label = '' + register;
+    }
+
+    onclick() {
+        x = '' + memory[this.register];
+        mode = 'scientific';
+        entryTerminated = true;
+        liftOnNumberPress = true;
+        feedback(true);
+        updateDisplay();
+        drawButtons();
+    }
+}
+
+const BUTTONS = {
+    'number': [
+        [new NumberButton(7), new NumberButton(8), new NumberButton(9), new ModeButton('number')],
+        [new NumberButton(4), new NumberButton(5), new NumberButton(6), new NumberButton(0)],
+        [new NumberButton(1), new NumberButton(2), new NumberButton(3), DecimalPointButton]
+    ],
+    'operation': [
+        [new OperationButton('+'), new OperationButton('-'), ClearButton, new ModeButton('operation')],
+        [new OperationButton('*'), new OperationButton('/'), SwapButton, EnterButton],
+        [new OperationButton('^'), new OneNumOpButton('+-'), RotateButton, ScientificButton]
+    ],
+    'scientific': [
+        [new OneNumOpButton('Sin'), new OneNumOpButton('Cos'), new OneNumOpButton('Tan'), new ModeButton('scientific')],
+        [new OneNumOpButton('Asin'), new OneNumOpButton('Acos'), new OneNumOpButton('Atan'), MemStoreButton],
+        [new OneNumOpButton('Log'), new ConstantButton('e', Math.E), new ConstantButton('pi', Math.PI), MemRecallButton]
+    ],
+    'memstore': [
+        [new MemStoreIn(7), new MemStoreIn(8), new MemStoreIn(9), new ModeButton('memstore')],
+        [new MemStoreIn(4), new MemStoreIn(5), new MemStoreIn(6), new MemStoreIn(0)],
+        [new MemStoreIn(1), new MemStoreIn(2), new MemStoreIn(3), new ModeButton('memstore')]
+    ],
+    'memrec': [
+        [new MemRecFrom(7), new MemRecFrom(8), new MemRecFrom(9), new ModeButton('memrec')],
+        [new MemRecFrom(4), new MemRecFrom(5), new MemRecFrom(6), new MemRecFrom(0)],
+        [new MemRecFrom(1), new MemRecFrom(2), new MemRecFrom(3), new ModeButton('memrec')]
+    ],
+};
+
+let x = '0';
+let y = 0;
+let z = 0;
+let t = 0;
+let memJSON = storage.readJSON(MEMORY_FILE);
+if (memJSON) {
+    let memory = memJSON;
+} else {
+    let memory = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
+}
+let mode = 'number';
+let entryTerminated = false;
+let liftOnNumberPress = false;
+
+function liftStack() {
+    t = z;
+    z = y;
+    y = parseFloat(x);
+}
+
+function feedback(acceptable) {
+    if (acceptable) Bangle.buzz(50, 0.5);
+    else Bangle.buzz(200, 1);
+}
+
+function drawButtons() {
+    g.reset().clearRect(0, 44, 175, 175).setFont("Vector", 15).setFontAlign(0, 0);
+    //Draw lines
+    for (let x = 44; x <= 176; x += 44) {
+        g.drawLine(x, 44, x, 175);
+    }
+    for (let y = 44; y <= 176; y += 44) {
+        g.drawLine(0, y, 175, y);
+    }
+    for (let row = 0; row < 3; row++) {
+        for (let col = 0; col < 4; col++) {
+            g.drawString(BUTTONS[mode][row][col].label, 22 + 44 * col, 66 + 44 * row);
+        }
+    }
+}
+
+function getFontSize(length) {
+    let size = Math.floor(176 / length);  //Characters of width needed per pixel
+    size *= (20 / 12);  //Convert to height
+    // Clamp to between 6 and 20
+    if (size < 6) return 6;
+    else if (size > 20) return 20;
+    else return Math.floor(size);
+}
+
+function updateDisplay() {
+    g.clearRect(0, 24, 175, 43).setColor(storage.readJSON('setting.json').theme.fg2).setFontAlign(1, -1).setFont("Vector", getFontSize(x.length)).drawString(x, 176, 24);
+}
+
+Bangle.on("touch", (button, xy) => {
+    let row = Math.floor((xy.y - 44) / 44);
+    let col = Math.floor(xy.x / 44);
+    if (row < 0) {  // Tap number to show registers
+        g.clearRect(0, 24, 175, 43).setColor(storage.readJSON('setting.json').theme.fg2).setFontAlign(1, -1)
+            .setFont("Vector", getFontSize(x.length)).drawString('' + t, 176, 24);
+
+        g.clearRect(0, 44, 175, 63).setColor(storage.readJSON('setting.json').theme.fg2).setFontAlign(1, -1)
+            .setFont("Vector", getFontSize(x.length)).drawString('' + z, 176, 44);
+
+        g.clearRect(0, 64, 175, 83).setColor(storage.readJSON('setting.json').theme.fg2).setFontAlign(1, -1)
+            .setFont("Vector", getFontSize(x.length)).drawString('' + y, 176, 64);
+
+        g.clearRect(0, 84, 175, 103).setColor(storage.readJSON('setting.json').theme.fg2).setFontAlign(1, -1)
+            .setFont("Vector", getFontSize(x.length)).drawString(x, 176, 84);
+
+        setTimeout(() => {
+            drawButtons();
+            updateDisplay();
+        }, 500);
+    } else {
+        if (row > 2) row = 2;
+        if (col < 0) col = 0;
+        if (col > 3) col = 3;
+
+        BUTTONS[mode][row][col].onclick();
+    }
+});
+
+Bangle.on("swipe", dir => {
+    if (dir == -1) {
+        if (entryTerminated) ClearButton.onclick();
+        else if (x.length == 1) x = '0';
+        else x = x.substring(0, x.length - 1);
+
+        feedback(true);
+        updateDisplay();
+    } else if (dir == 0) {
+        EnterButton.onclick();
+    }
+});
+
+g.clear().reset();
+
+drawButtons();
+updateDisplay();
+
+Bangle.loadWidgets();
+Bangle.drawWidgets();
\ No newline at end of file
diff --git a/apps/rpnsci/icon.js b/apps/rpnsci/icon.js
new file mode 100644
index 000000000..24ea29035
--- /dev/null
+++ b/apps/rpnsci/icon.js
@@ -0,0 +1 @@
+require("heatshrink").decompress(atob("mEwwcCkmSpICEpEEBAwCICP4CCk/yCP4RVyf/AAXkCK0///5Nf4RffcYR/AQkAAERr/CKn+CK9//+f/41O/mT5IRO/+eLJ8/CIw+BAAP8CIkn+QRQMQY1MCKM8z5rP8mf/KzO8mTCJ1/CIP/8j7pCP4RMA=="))
\ No newline at end of file
diff --git a/apps/rpnsci/icon.png b/apps/rpnsci/icon.png
new file mode 100644
index 000000000..474abf7e3
Binary files /dev/null and b/apps/rpnsci/icon.png differ
diff --git a/apps/rpnsci/icon.xcf b/apps/rpnsci/icon.xcf
new file mode 100644
index 000000000..9a41dbe32
Binary files /dev/null and b/apps/rpnsci/icon.xcf differ
diff --git a/apps/rpnsci/metadata.json b/apps/rpnsci/metadata.json
new file mode 100644
index 000000000..0c52aa8a7
--- /dev/null
+++ b/apps/rpnsci/metadata.json
@@ -0,0 +1,24 @@
+{
+    "id": "rpnsci",
+    "name": "RPN Scientific Calculator",
+    "shortName": "Calculator",
+    "icon": "icon.png",
+    "version": "0.03",
+    "description": "RPN scientific calculator with memory function.",
+    "tags": "",
+    "supports": [
+        "BANGLEJS2"
+    ],
+    "readme": "README.md",
+    "storage": [
+        {
+            "name": "rpnsci.app.js",
+            "url": "app.js"
+        },
+        {
+            "name": "rpnsci.img",
+            "url": "icon.js",
+            "evaluate": "true"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/apps/setting/ChangeLog b/apps/setting/ChangeLog
index cd97e1dda..55b61c46a 100644
--- a/apps/setting/ChangeLog
+++ b/apps/setting/ChangeLog
@@ -58,3 +58,4 @@
 0.51: Add setting for configuring a launcher
 0.52: Add option for left-handed users
 0.53: Ensure that when clock is set, clockHasWidgets is set correctly too
+0.54: If setting.json is corrupt, ensure it gets re-written
diff --git a/apps/setting/metadata.json b/apps/setting/metadata.json
index 47d0a309f..08544cff6 100644
--- a/apps/setting/metadata.json
+++ b/apps/setting/metadata.json
@@ -1,7 +1,7 @@
 {
   "id": "setting",
   "name": "Settings",
-  "version": "0.53",
+  "version": "0.54",
   "description": "A menu for setting up Bangle.js",
   "icon": "settings.png",
   "tags": "tool,system",
diff --git a/apps/setting/settings.js b/apps/setting/settings.js
index d7bb060ea..2350a8965 100644
--- a/apps/setting/settings.js
+++ b/apps/setting/settings.js
@@ -60,7 +60,9 @@ function resetSettings() {
 }
 
 settings = storage.readJSON('setting.json', 1);
-if (!settings) resetSettings();
+if (("object" != typeof settings) ||
+    ("object" != typeof settings.options))
+  resetSettings();
 
 const boolFormat = v => v ? /*LANG*/"On" : /*LANG*/"Off";
 
@@ -237,9 +239,9 @@ function showThemeMenu() {
     }
   };
 
-  require("Storage").list(/^.*\.theme$/).forEach(
+  storage.list(/^.*\.theme$/).forEach(
     n => {
-      let newTheme = require("Storage").readJSON(n);
+      let newTheme = storage.readJSON(n);
       themesMenu[newTheme.name ? newTheme.name : n] = () => {
         upd({
         fg:cl(newTheme.fg), bg:cl(newTheme.bg),
@@ -567,11 +569,11 @@ function showUtilMenu() {
     },
     /*LANG*/'Compact Storage': () => {
       E.showMessage(/*LANG*/"Compacting...\nTakes approx\n1 minute",{title:/*LANG*/"Storage"});
-      require("Storage").compact();
+      storage.compact();
       showUtilMenu();
     },
     /*LANG*/'Rewrite Settings': () => {
-      require("Storage").write(".boot0","eval(require('Storage').read('bootupdate.js'));");
+      storage.write(".boot0","eval(require('Storage').read('bootupdate.js'));");
       load("setting.app.js");
     },
     /*LANG*/'Flatten Battery': () => {
@@ -592,9 +594,9 @@ function showUtilMenu() {
     menu[/*LANG*/'Calibrate Battery'] = () => {
       E.showPrompt(/*LANG*/"Is the battery fully charged?",{title:/*LANG*/"Calibrate"}).then(ok => {
         if (ok) {
-          var s=require("Storage").readJSON("setting.json");
+          var s=storage.readJSON("setting.json");
           s.batFullVoltage = (analogRead(D3)+analogRead(D3)+analogRead(D3)+analogRead(D3))/4;
-          require("Storage").writeJSON("setting.json",s);
+          storage.writeJSON("setting.json",s);
           E.showAlert(/*LANG*/"Calibrated!").then(() => load("setting.app.js"));
         } else {
           E.showAlert(/*LANG*/"Please charge Bangle.js for 3 hours and try again").then(() => load("settings.app.js"));
@@ -659,7 +661,7 @@ function makeConnectable() {
   });
 }
 function showClockMenu() {
-  var clockApps = require("Storage").list(/\.info$/)
+  var clockApps = storage.list(/\.info$/)
     .map(app => {var a=storage.readJSON(app, 1);return (a&&a.type == "clock")?a:undefined})
     .filter(app => app) // filter out any undefined apps
     .sort((a, b) => a.sortorder - b.sortorder);
@@ -676,7 +678,7 @@ function showClockMenu() {
     }
     clockMenu[label] = () => {
       settings.clock = app.src;
-      settings.clockHasWidgets = require("Storage").read(app.src).includes("Bangle.loadWidgets");
+      settings.clockHasWidgets = storage.read(app.src).includes("Bangle.loadWidgets");
       updateSettings();
       showMainMenu();
     };
@@ -687,7 +689,7 @@ function showClockMenu() {
   return E.showMenu(clockMenu);
 }
 function showLauncherMenu() {
-  var launcherApps = require("Storage").list(/\.info$/)
+  var launcherApps = storage.list(/\.info$/)
     .map(app => {var a=storage.readJSON(app, 1);return (a&&a.type == "launch")?a:undefined})
     .filter(app => app) // filter out any undefined apps
     .sort((a, b) => a.sortorder - b.sortorder);
@@ -865,9 +867,9 @@ function showTouchscreenCalibration() {
     Bangle.setOptions({
       touchX1: calib.x1, touchY1: calib.y1, touchX2: calib.x2, touchY2: calib.y2
     });
-    var s = require("Storage").readJSON("setting.json",1)||{};
+    var s = storage.readJSON("setting.json",1)||{};
     s.touch = calib;
-    require("Storage").writeJSON("setting.json",s);
+    storage.writeJSON("setting.json",s);
     g.setFont("6x8:2").setFontAlign(0,0).drawString("Calibrated!", g.getWidth()/2, g.getHeight()/2);
     // now load the main menu again
     setTimeout(showLCDMenu, 500);
diff --git a/apps/slopeclockpp/ChangeLog b/apps/slopeclockpp/ChangeLog
new file mode 100644
index 000000000..aef4d7324
--- /dev/null
+++ b/apps/slopeclockpp/ChangeLog
@@ -0,0 +1,2 @@
+0.01: Clone of original SlopeClock
+0.02: Added configuration
diff --git a/apps/slopeclockpp/app-icon.js b/apps/slopeclockpp/app-icon.js
new file mode 100644
index 000000000..bd62b928d
--- /dev/null
+++ b/apps/slopeclockpp/app-icon.js
@@ -0,0 +1 @@
+require("heatshrink").decompress(atob("mEw4P/AAMA/Ayq8EH8AEBgfgj4zCj/gn/8Aod//wFDvk/gEEAoP4AoMAEIP4j4FFwAFC/gFEv//ApM/74FDg4XBgZLCFIMzAoU4g8BK4dwgMP+Ewg+AgMfK4PhAoXwh+B/0Bj0B/4FBgYnB/8B/kDgf/+ED/kHn//HgIFBW4IFB/AFDgf4h4FB+EBFgLKCAoInBAAOAAoqkBAgPAWAIuBAoXAn+zCAMB4F/8YFBgYFB4YFBRgY7BYwIoCABX4zkY74FB/mMiALC/3mug6CAAgA=="))
diff --git a/apps/slopeclockpp/app.js b/apps/slopeclockpp/app.js
new file mode 100644
index 000000000..96648dd54
--- /dev/null
+++ b/apps/slopeclockpp/app.js
@@ -0,0 +1,239 @@
+Graphics.prototype.setFontPaytoneOne = function(scale) {
+  // Actual height 81 (91 - 11)
+  this.setFontCustom(
+    E.toString(require('heatshrink').decompress(atob('AH8AgP/BpcD//gBpn4Bpn+Bpn/wANMHBRTB//wBphGLBoJGLv4OBBpU/KhkfBoPABpMPMRkHMRh+CMRRwC/hwmMQQNKMQTTNBpRGCRhSpCBpY4BFJY4BBpcAjgMLAHUwBpl4BhcBd5Z/Bd5abCBpa3BTZd/YpcBcIPgBpMHBoPwIhf//BEL/5wKIgP/OBJECAAJELAAJwIIgQABOBBECOBRECOBJEEOBBEEOBBEEOBBEEOBBEEOA5EFBo5EFFI5EFKY5EGN4woGTIpEpj5EMDYzeGG4xEFgEDWZhhFbo59FfI7QFIgynGIgxwGBg5wEIhBwE+ANIOAZEIOAhEIOAgMJOAREJOAZEJOAZEJOAZEKOAQMKOAJELOAJELAAJELAH0EBhaQBSJa6BZJbkCDhMDBof4XJIADBpvAKRIqKBov+Bo0fBogqHBozpGBoyAGBoxjGBo44FBo44FMIpxHBo5xFBo7HFU4pGHBpBGEBpB/EdohGIgINHIwgNJIwgWEn4EC8ANGQ4SNHv4VEQgRUEEgQxCHwRUEYgRNDEQQNKFQRUDAwQNDQoRUDTQQUDHASpDCgR3EHAJiDCgR3ELYJiEBow/BMQgiBbQ4iFSYg/CLYZwBGAg/COAwNGOAwiDJoRwUKggNBOAwGEBoJwEcIT2GaYw4DAoINEMQQ/CHwRbEMQQHCLQTaHI4QvCNIoHCAArMEJoQAFO4gkDBpJUCAAraHBpRUDAAihEIxANFIw4NFIw7EEIxANFRo4NGcQQNKHAwNGHAwNGHAwNHHAoNHf4YNJVQqLFFQ7DEFRDtEKpHgBpCADwANIDgRSHKwvABpQA/AFp7BZwkfXIyXFVoLVFv//bArxFBoLBDga6GfgK0DHwIiEH4TrEcgw/BJogwBa4g/BJogwBEQgNGOAxNBAAwUEJoQAFOAoNHOAoNHOApbBAAxwEBpBwENIIAGOAgNIOAh3BOBYNIOAi2BOBYNIOAgNJOAbEBOBbEIOAjEIOAoNIOAioIOAiaIOAiMIOH5wLAAw/BOAgAGH4JwEAAw/CBpQ/COAYAHWAJwDAA6wBOAYAHWAJwEAAywBODIA/ABsDUBYNBOwpwGZgIcEcIwNBDggNBcIraFBoQjEbQK+DBoThEBoIqDBoThEdAJNDBoThEBpBNEewJbDBoRwEewINGOAiFBNIYNCOAgNJO5INDOAaaBAwYNDOAgGEBoZwEBpBwEVAgNDOAiMBCgQNDOAiMBCgRnCOAqMEBohwDPwgNEOAZ+EBohwDPwQGBFwJwJAwINEOAxUBLAP/+5wHIwIDC/ZwHHAInC/JwHAAn4OBAAD/g/BOAwNEHYJwGBog/BOAgiBAAf+H4JwELwQNDH4JwEMQQNDH4JwEMQv+H4QNDKgoYBOApUGJoRwDKgxNCOAZUGJoRwEIwoGCOAhGFWARwEIwoUCOAhGEBIJwGRogXCOAriEBoRwGHAZBCOAxxDBoRwGFQZrCOAxADEgRwGCwZOCOA4A/AEMBXggAISQ0AjCZFZYgjBTQt/AwqgBBoraFfozgBbQgNBGIgNGEQIGEewJVECgIGEHwJGEAxr9BKggGBewImBfoRUEAwQ7CBIJUFgINCFoIJBO4oNCwAtBBIJ3JFoIJBFoJNEEQQfBBIJNDRgwJCJoaMGBIQ/DPwgNBFoJiHRgYtBMQ4+DFoJiHHwYfBMQbFDPwoJBXww+CFoZwGHwQtDOAz2CFoZwGUIQJCTwRwGGAIJBTwRwGEQICBKAIRDOAngAQJCBJoJwGAAfhD4ZwEAAxwGBpZiBAA4NDMQIAHPwZiCAAx+DMQQNKKhKMDKhKMDKhINEKgf7BoaaDIwn5BpCpD/A8DVAhGD/g8DBooJC/g8DBoqNC/A8DWwg4DIAINIe4k/BpA0BPAI4CBowmBWAI4CBo4uFKYoAFM4KLEAAxZBWogA/ADSMBRZaaCBpTlCwANMXYIAIaQXgBpioKBoTEKaILgLBoRwKn4NBOBQNDOBINDOBN/BoRwJBoZwJBgRwKBoZwJBoZwIgILCOBINDJAJwHfQX8OQJwHBoaqBOA4NC/DUBOA8HBoQDBOA4NC+AfBOA76C8BXBOA4NDQIQNJLwJwILoINCOBANCC4JwIfQQNBOBAbCMwZwGIoQAGJAZ9CAAxIDU4QAGJAbfCAAxIEBpBIEQ4IAGXIhwCAAq5EOAQAGOH5w/OH5wvBoYAELIInEAA4ZKLIiYDAA5ZBTAYAHLIKYDAA5ZBTAgAGZQKYEAAzKBTAhwjAH4A8U4LRCh7xGS4LRCcYwGBAATDBAwLjEBojDBeILVEAwIADwA7Baoj4BAAfAcYLVECgIADGgIRCfAgAD/EAn5UFBohUIv4OEKg4iBKghNBKghwEGgJNCOBJCBD4RwIIQI/BMQZwHH4JUDOArFDOgJwHBIJiGOAQtBBoJiGSYQNBC4JiGSYTPDH4RiDGAP4Z4jFFGAImBBoY/BYoYmDEoZwIRAhwIwDrDBoJwG4AXDJoJwHRAbMCOAzICZgZwGRAXADYRwGK4X4EQLhGOAYADPwZwFcopwHcopwHBpBwEAAaMEOAoACRgjhFBo7hFAAYNDOAZiFBoZwDKgqoDOAZUFBohwCW4QNHfQYNEWwZwDCIQNHGgINBIwgNEOAIDDBo8DLAoNGAAg4DBpJxDMIgAEXAYNJFQYMJXgTtEAA8HIhIA/ACp9BN5SZD8B7JBoX+YZjSJb4f//ANMYpF/BogqHBovwBowMEKpANF/+ABpiAGBoxjGBoyrGBoxxGBo5xFBo5xFPopGHBo5/FBo5GFYYpGHBpCNEj5UMBpCNEh4ICw//g5UGA4X8AYOAHwQNG/EDBoIGCcQYJBH4IDB4EBKgoGCBoQJBQoJUDBoYDBBIJbBVIgNGHAJiEEQIUBAQQtBMQhbBBoQXBGISMFBQN/C4RiFRgIKBD4IxDYoY+BBoIfBC4IRBOAZ+CBoQJBAYJwGwAtBBIIDBOA3AFoIJBOBHgNgY/DOAiMCHYLFCOAp+CFoZwGPwQRBAwINEGAb6CAAR+DGgYtBAAZ+DGgYmCBo5iCIQQACRgZiGAASMEKgYNJKgYtBAASaEYoZiEBohUIVAhUIBoomB/BUEBopUIBoipIBogmBDYJGEBogmBO4JmCBo8/V4QNJh7nCHAYNFgxYEMIxKGBpYqCU4oAFOoLtEAA8PBhYA/AB9///AQ5jFCABEfQ47MCYAbvBXQgiEUYKxFg4iEgbNGh4UEbgRNFCgoNBH4hpBOBYUBAwhwFHwJ3FOApaBNIpwFCYJpFOAovBNIpwFBgJbFOAgECKgwUDIgQABTYhwDJQIACKghwDKQRGGOAYfBAAZwHBghUEOASXCAAaiF/xSEKgprCIgibGAwO/BopUEKApwJAAyMEGoyoGSwhvHWQqLHOARgKbgpSHfAqYGOBJSEOBAMFOAyXEOBBEGOAyXEOBBEGOAyXEOA5EHOAqXFOA5EHOAqXGOAxEIOAgMIOAZEJOAaXHMQpEJAH4AOn6QJbIaDKQgYcKUATXJVxwNCZQ8fCwIND4C4H4ANDHAzUCBoY4GBAP+MIQEBBo//4IDCOIoXD+ANDewozDBoZGFBIZXBIw4NDAAZGFBo6NFEoYAERogNIKgk/Bo5UEBpBUEj5UMh5UMBpKpDg4KFAwRUDbgP4JARCBKgrEB/AsC/BNCAYINEfYQJBCQJiEBIQpDCQJiEv4JBHAT2DRggTBQIReBWAJiDBQJlDYIIgBYoY+BwBGCLwIVBOAYYBCYJUFOAYYBCYIzBHgIVBOAoTBKgYVBOA6NCwAVBOA6zEOAwlDSIhwF4ANCEAJKBOAvwcgYNCOAv/TQQYBGILhFAAn4DYJwDHwQAGBogUBAAx+ERIQAFPwiJCAAwNDL4YNJPYQAGRgZUJRgZUJBoiKC/wNETQZGEMwiaDIwhmEBohGDMwgNFEwS7EVAiNDLAgNFDARYDBowqBWAJGDBo0DH4JYDaQgAFDZKRGBpRxCBpQqCPooAFKoLDEAA8cBhYA/ACM/8AMKcQYAJaASXKWYTdDgwNI/+AawSyHAAJHCn64FBobeCHgwND/xLCeAoNDHAIFBCIINI8BnCKZA0BQYRGEBohxBv5YDBow0Bn5UFGIRGFSIYNG4AiBKgg/CKhQNFPYJUGBohUIBohUICgIADSYSpECgJiEKgwNCKAXAKg0fCgRCCLYWAYggNBCIJiHGAYDBBoJiFGAINBEwJwBMQowCOgQtFPwh0DH4TFEJgYYBOA4XBJgIYBaYRwEHwJMBBQLTDOAYlBJgIKBPwZwFHwIKB+ANCOA5KBD4INBOAwwBTQhwGGAN/BpBiBEQM/HYINBPwhiBS4X8GAR+EMQI4BBoJvCPwiFC/kPAIINGCof//oEDRgYxCAAwNDKgQAGTQZUCBpZUCAAqoDKgYNKKggADWwapDBpZGHBopGHBopGHBoqNHBoqNHBow4GBow4GBow4GBow4GTIgACfIYNJFQrREFRD7EKo/+Bg7HE/ANJDgQ2IeYZRHAH4AmgaYDn50HRgKLCv/8BpD6CZQINIC4QNBVgy2CBoYgCIojEDBoI4GBoRQBn7yHgLuDBoJGGBoQlBj7zIBAIlBh4uDAAhBBEoJYCKgwzCwBKCHgIAEGYY8EAAgzEHgaMHGYI8DPw5wEwBwTEoJwLUgatEMQ4uDPwzhNC4RPBEAKMGC4QNBEAINHC4INBEAIpGKAQgDBo8AnASDRYoAnA='))),
+    46,
+    atob("ITZOMzs7SDxHNUdGIQ=="),
+    113+(scale<<8)+(1<<16)
+  );
+  return this;
+};
+
+{ // must be inside our own scope here so that when we are unloaded everything disappears
+  // we also define functions using 'let fn = function() {..}' for the same reason. function decls are global
+
+let settings = Object.assign(
+  require("Storage").readJSON("slopeclockpp.default.json", true) || {},
+  require("Storage").readJSON("slopeclockpp.json", true) || {}
+);
+
+let drawTimeout;
+
+let g2 = Graphics.createArrayBuffer(g.getWidth(),90,1,{msb:true});
+let g2img = {
+  width:g2.getWidth(), height:g2.getHeight(), bpp:1,
+  buffer:g2.buffer, transparent:0
+};
+const slope = 20;
+const offsy = 20; // offset of numbers from middle
+const fontBorder = 4; // offset from left/right
+const slopeBorder = 10, slopeBorderUpper = 4; // fudge-factor to move minutes down from slope
+let R,x,y; // middle of the clock face
+let dateStr = "";
+let bgColors = [];
+if (g.theme.dark) {
+   if (settings.colorYellow) bgColors.push("#ff0");
+   if (settings.colorCyan) bgColors.push("#0ff");
+   if (settings.colorMagenta) bgColors.push("#f0f");
+} else {
+   if (settings.colorRed) bgColors.push("#f00");
+   if (settings.colorGreen) bgColors.push("#0f0");
+   if (settings.colorBlue) bgColors.push("#00f");
+}
+let bgColor = bgColors[(Math.random()*bgColors.length)|0];
+
+
+// Draw the hour, and the minute into an offscreen buffer
+let draw = function() {
+  R = Bangle.appRect;
+  x = R.w / 2;
+  y = R.y + R.h / 2 - 12; // 12 = room for date
+  var date = new Date();
+  var hourStr = date.getHours();
+  var minStr = date.getMinutes().toString().padStart(2,0);
+  dateStr = require("locale").dow(date, 1).toUpperCase()+ " "+
+            require("locale").date(date, 0).toUpperCase();
+
+  // Draw hour
+  g.reset().clearRect(R); // clear whole background (w/o widgets)
+  g.setFontAlign(-1, 0).setFont("PaytoneOne");
+  g.drawString(hourStr, fontBorder, y-offsy);
+  // add slope in background color
+  g.setColor(g.theme.bg).fillPoly([0,y+slope-slopeBorderUpper, R.w,y-slope-slopeBorderUpper,
+                                   R.w,y-slope, 0,y+slope]);
+
+  // Draw minute to offscreen buffer
+  g2.setColor(0).fillRect(0,0,g2.getWidth(),g2.getHeight()).setFontAlign(1, 0).setFont("PaytoneOne");
+  g2.setColor(1).drawString(minStr, g2.getWidth()-fontBorder, g2.getHeight()/2);
+  g2.setColor(0).fillPoly([0,0, g2.getWidth(),0, 0,slope*2]);
+  // start the animation *in*
+  animate(true);
+
+  // queue next draw
+  if (drawTimeout) clearTimeout(drawTimeout);
+  drawTimeout = setTimeout(function() {
+    drawTimeout = undefined;
+    animate(false, function() {
+      draw();
+    });
+  }, 60000 - (Date.now() % 60000));
+};
+
+let isAnimIn = true;
+let animInterval;
+// Draw *just* the minute image
+let drawMinute = function() {
+  var yo = slopeBorder + offsy + y - 2*slope*minuteX/R.w;
+  // draw over the slanty bit
+  g.setColor(bgColor).fillPoly([0,y+slope, R.w,y-slope, R.w,R.h+R.y, 0,R.h+R.y]);
+  // draw the minutes
+  g.setColor(g.theme.bg).drawImage(g2img, x+minuteX-(g2.getWidth()/2), yo-(g2.getHeight()/2));
+};
+let animate = function(isIn, callback) {
+  if (animInterval) clearInterval(animInterval);
+  isAnimIn = isIn;
+  minuteX = isAnimIn ? -g2.getWidth() : 0;
+  drawMinute();
+  animInterval = setInterval(function() {
+    minuteX += 8;
+    let stop = false;
+    if (isAnimIn && minuteX>=0) {
+      minuteX=0;
+      stop = true;
+    } else if (!isAnimIn && minuteX>=R.w)
+      stop = true;
+    drawMinute();
+    if (stop) {
+      clearInterval(animInterval);
+      animInterval=undefined;
+      if (isAnimIn) {
+        // draw the date
+        g.setColor(g.theme.bg).setFontAlign(0, 0).setFont("6x15").drawString(dateStr, R.x + R.w/2, R.y+R.h-9);
+
+        if (settings.showSteps) {
+        // draw steps to bottom left
+        const steps = getSteps();
+        if (steps > 0)
+           g.setFontAlign(-1, 0).drawString(shortValue(steps), 3, R.y+R.h-30);
+        }
+
+        if (settings.showWeather) {
+          // draw weather to top right
+          const weather = getWeather();
+          const tempString = weather ? require("locale").temp(weather.temp - 273.15) : undefined;
+          const code = weather ? weather.code : -1;
+          if (code > -1) {
+            g.setColor(g.theme.fg).setFontAlign(1, 0).drawString(tempString, R.w - 3, y-slope-slopeBorderUpper);
+            const icon = getWeatherIconByCode(code);
+            if (icon) g.drawImage(icon, R.w - 3 - 15, y-slope-slopeBorderUpper - 15 - 15);
+          }
+        }
+      }
+      if (callback) callback();
+    }
+  }, 20);
+};
+
+let getSteps = function() {
+  if (Bangle.getHealthStatus) {
+    return Bangle.getHealthStatus("day").steps;
+  }
+  if (WIDGETS && WIDGETS.wpedom !== undefined) {
+    return WIDGETS.wpedom.getSteps();
+  }
+  return 0;
+};
+
+let shortValue = function(v) {
+  if (isNaN(v)) return '-';
+  if (v <= 999) return v;
+  if (v >= 1000 && v < 10000) {
+    v = Math.floor(v / 100) * 100;
+    return (v / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
+  }
+  if (v >= 10000) {
+    v = Math.floor(v / 1000) * 1000;
+    return (v / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
+  }
+};
+
+let getWeather = function() {
+  let jsonWeather = require("Storage").readJSON('weather.json');
+  return jsonWeather && jsonWeather.weather ? jsonWeather.weather : undefined;
+};
+
+/*
+ * Choose weather icon to display based on weather conditition code
+ * https://openweathermap.org/weather-conditions#Weather-Condition-Codes-2
+ */
+let getWeatherIconByCode = function(code) {
+  let codeGroup = Math.round(code / 100);
+
+  // weather icons:
+  let weatherCloudy = atob("EBCBAAAAAAAAAAfgD/Af8H/4//7///////9//z/+AAAAAAAA");
+  let weatherSunny = atob("EBCBAAAAAYAQCBAIA8AH4A/wb/YP8A/gB+ARiBAIAYABgAAA");
+  let weatherMoon = atob("EBCBAAAAAYAP8B/4P/w//D/8f/5//j/8P/w//B/4D/ABgAAA");
+  let weatherPartlyCloudy = atob("EBCBAAAAAAAYQAMAD8AIQBhoW+AOYBwwOBBgHGAGP/wf+AAA");
+  let weatherRainy = atob("EBCBAAAAAYAH4AwwOBBgGEAOQAJBgjPOEkgGYAZgA8ABgAAA");
+  let weatherPartlyRainy = atob("EBCBAAAAEEAQAAeADMAYaFvoTmAMMDgQIBxhhiGGG9wDwAGA");
+  let weatherSnowy = atob("EBCBAAAAAAADwAGAEYg73C50BCAEIC50O9wRiAGAA8AAAAAA");
+  let weatherFoggy = atob("EBCBAAAAAAADwAZgDDA4EGAcQAZAAgAAf74AAAAAd/4AAAAA");
+  let weatherStormy = atob("EBCBAAAAAYAH4AwwOBBgGEAOQMJAgjmOGcgAgACAAAAAAAAA");
+  let unknown = undefined;
+
+  switch (codeGroup) {
+    case 2:
+      return weatherStormy;
+    case 3:
+      return weatherCloudy;
+    case 5:
+      switch (code) {
+        case 511:
+          return weatherSnowy;
+        case 520:
+          return weatherPartlyRainy;
+        case 521:
+          return weatherPartlyRainy;
+        case 522:
+          return weatherPartlyRainy;
+        case 531:
+          return weatherPartlyRainy;
+        default:
+          return weatherRainy;
+      }
+      case 6:
+        return weatherSnowy;
+      case 7:
+        return weatherFoggy;
+      case 8:
+        switch (code) {
+          case 800:
+            return weatherSunny;
+          case 801:
+            return weatherPartlyCloudy;
+          case 802:
+            return weatherPartlyCloudy;
+          default:
+            return weatherCloudy;
+        }
+      default:
+        return unknown;
+  }
+}
+
+// Show launcher when middle button pressed
+Bangle.setUI({
+  mode : "clock",
+  remove : function() {
+    // Called to unload all of the clock app
+    if (animInterval) clearInterval(animInterval);
+    animInterval = undefined;
+    if (drawTimeout) clearTimeout(drawTimeout);
+    drawTimeout = undefined;
+    delete Graphics.prototype.setFontPaytoneOne;
+  }});
+// Load widgets
+Bangle.loadWidgets();
+draw();
+setTimeout(Bangle.drawWidgets,0);
+}
diff --git a/apps/slopeclockpp/app.png b/apps/slopeclockpp/app.png
new file mode 100644
index 000000000..2f5912fcf
Binary files /dev/null and b/apps/slopeclockpp/app.png differ
diff --git a/apps/slopeclockpp/default.json b/apps/slopeclockpp/default.json
new file mode 100644
index 000000000..448d64f6a
--- /dev/null
+++ b/apps/slopeclockpp/default.json
@@ -0,0 +1,10 @@
+{
+  "showSteps": true,
+  "showWeather": true,
+  "colorRed": true,
+  "colorGreen": true,
+  "colorBlue": true,
+  "colorYellow": true,
+  "colorMagenta": true,
+  "colorCyan": true
+}
diff --git a/apps/slopeclockpp/metadata.json b/apps/slopeclockpp/metadata.json
new file mode 100644
index 000000000..fc6d3e415
--- /dev/null
+++ b/apps/slopeclockpp/metadata.json
@@ -0,0 +1,19 @@
+{ "id": "slopeclockpp",
+  "name": "Slope Clock ++",
+  "version":"0.02",
+  "description": "A clock where hours and minutes are divided by a sloping line. When the minute changes, the numbers slide off the screen. This is a clone of the original Slope Clock which can show the weather and steps.",
+  "icon": "app.png",
+  "screenshots": [{"url":"screenshot.png"}],
+  "type": "clock",
+  "tags": "clock",
+  "supports" : ["BANGLEJS2"],  
+  "storage": [
+    {"name":"slopeclockpp.app.js","url":"app.js"},
+    {"name":"slopeclockpp.img","url":"app-icon.js","evaluate":true},
+    {"name":"slopeclockpp.settings.js","url":"settings.js"},
+    {"name":"slopeclockpp.default.json","url":"default.json"}
+   ],
+   "data": [
+     {"name":"slopeclockpp.json"}
+   ]
+}
diff --git a/apps/slopeclockpp/screenshot.png b/apps/slopeclockpp/screenshot.png
new file mode 100644
index 000000000..93970956c
Binary files /dev/null and b/apps/slopeclockpp/screenshot.png differ
diff --git a/apps/slopeclockpp/settings.js b/apps/slopeclockpp/settings.js
new file mode 100644
index 000000000..a9319cd99
--- /dev/null
+++ b/apps/slopeclockpp/settings.js
@@ -0,0 +1,64 @@
+(function(back) {
+  const SETTINGS_FILE = "slopeclockpp.json";
+  const storage = require('Storage');
+  let settings = Object.assign(
+    storage.readJSON("slopeclockpp.default.json", true) || {},
+    storage.readJSON(SETTINGS_FILE, true) || {}
+  );
+
+  function save(key, value) {
+    settings[key] = value;
+    storage.write(SETTINGS_FILE, settings);
+  }
+
+  function showMainMenu() {
+    let menu ={
+      '': { 'title': 'Slope Clock ++' },
+      /*LANG*/'< Back': back,
+      /*LANG*/'show steps': {
+        value: !!settings.showSteps,
+        format: () => (settings.showSteps ? 'Yes' : 'No'),
+        onchange: x => save('showSteps', x),
+      },
+      /*LANG*/'show weather': {
+        value: !!settings.showWeather,
+        format: () => (settings.showWeather ? 'Yes' : 'No'),
+        onchange: x => save('showWeather', x),
+      },
+      /*LANG*/'red': {
+        value: !!settings.colorRed,
+        format: () => (settings.colorRed ? 'Yes' : 'No'),
+        onchange: x => save('colorRed', x),
+      },
+      /*LANG*/'green': {
+        value: !!settings.colorGreen,
+        format: () => (settings.colorGreen ? 'Yes' : 'No'),
+        onchange: x => save('colorGreen', x),
+      },
+      /*LANG*/'blue': {
+        value: !!settings.colorBlue,
+        format: () => (settings.colorBlue ? 'Yes' : 'No'),
+        onchange: x => save('colorBlue', x),
+      },
+      /*LANG*/'magenta': {
+        value: !!settings.colorMagenta,
+        format: () => (settings.colorMagenta ? 'Yes' : 'No'),
+        onchange: x => save('colorMagenta', x),
+      },
+      /*LANG*/'cyan': {
+        value: !!settings.colorCyan,
+        format: () => (settings.colorCyan ? 'Yes' : 'No'),
+        onchange: x => save('colorCyan', x),
+      },
+      /*LANG*/'yellow': {
+        value: !!settings.colorYellow,
+        format: () => (settings.colorYellow ? 'Yes' : 'No'),
+        onchange: x => save('colorYellow', x),
+      }
+    };
+    E.showMenu(menu);
+  }
+
+
+  showMainMenu();
+});
diff --git a/apps/stlap/ChangeLog b/apps/stlap/ChangeLog
new file mode 100644
index 000000000..35ba8b130
--- /dev/null
+++ b/apps/stlap/ChangeLog
@@ -0,0 +1,3 @@
+0.01: New app!
+0.02: Bug fixes
+0.03: Submitted to the app loader
\ No newline at end of file
diff --git a/apps/stlap/app.js b/apps/stlap/app.js
new file mode 100644
index 000000000..0bd18311f
--- /dev/null
+++ b/apps/stlap/app.js
@@ -0,0 +1,304 @@
+const storage = require("Storage");
+const heatshrink = require("heatshrink");
+const STATE_PATH = "stlap.state.json";
+g.setFont("Vector", 24);
+const BUTTON_ICONS = {
+  play: heatshrink.decompress(atob("jEYwMAkAGBnACBnwCBn+AAQPgAQPwAQP8AQP/AQXAAQPwAQP8AQP+AQgICBwQUCEAn4FggyBHAQ+CIgQ")),
+  pause: heatshrink.decompress(atob("jEYwMA/4BBAX4CEA")),
+  reset: heatshrink.decompress(atob("jEYwMA/4BB/+BAQPDAQPnAQIAKv///0///8j///EP//wAQQICBwQUCEhgyCHAQ+CIgI="))
+};
+
+let state = storage.readJSON(STATE_PATH);
+const STATE_DEFAULT = {
+  wasRunning: false,              //If the stopwatch was ever running since being reset
+  sessionStart: 0,                //When the stopwatch was first started
+  running: false,                 //Whether the stopwatch is currently running
+  startTime: 0,                   //When the stopwatch was last started.
+  pausedTime: 0,                  //When the stopwatch was last paused.
+  elapsedTime: 0                  //How much time was spent running before the current start time. Update on pause.
+};
+if (!state) {
+  state = STATE_DEFAULT;
+}
+
+let lapFile;
+let lapHistory;
+if (state.wasRunning) {
+  lapFile = 'stlap-' + state.sessionStart + '.json';
+  lapHistory = storage.readJSON(lapFile);
+  if (!lapHistory)
+    lapHistory = {
+      final: false, //Whether the stopwatch has been reset. It is expected that the stopwatch app will create a final split when reset. If this is false, it is expected that this hasn't been done, and that the current time should be used as the "final split"
+      splits: []  //List of times when the Lap button was pressed
+    };
+} else
+  lapHistory = {
+    final: false, //Whether the stopwatch has been reset. It is expected that the stopwatch app will create a final split when reset. If this is false, it is expected that this hasn't been done, and that the current time should be used as the "final split"
+    splits: []  //List of times when the Lap button was pressed
+  };
+
+//Get the number of milliseconds that stopwatch has run for
+function getTime() {
+  if (!state.wasRunning) {
+    //If the timer never ran, zero ms have passed
+    return 0;
+  } else if (state.running) {
+    //If the timer is running, the time left is current time - start time + preexisting time
+    return (new Date()).getTime() - state.startTime + state.elapsedTime;
+  } else {
+    //If the timer is not running, the same as above but use when the timer was paused instead of now.
+    return state.pausedTime - state.startTime + state.elapsedTime;
+  }
+}
+
+let gestureMode = false;
+
+function drawButtons() {
+  //Draw the backdrop
+  const BAR_TOP = g.getHeight() - 48;
+  const BUTTON_Y = BAR_TOP + 12;
+  const BUTTON_LEFT = g.getWidth() / 4 - 12;    //For the buttons, we have to subtract 12 because images do not obey alignment, but their size is known in advance
+  const TEXT_LEFT = g.getWidth() / 4;           //For text, we do not have to subtract 12 because they do obey alignment.
+  const BUTTON_MID = g.getWidth() / 2 - 12;
+  const TEXT_MID = g.getWidth() / 2;
+  const BUTTON_RIGHT = g.getHeight() * 3 / 4 - 12;
+
+  g.setColor(0, 0, 1).setFontAlign(0, -1)
+    .clearRect(0, BAR_TOP, g.getWidth(), g.getHeight())
+    .fillRect(0, BAR_TOP, g.getWidth(), g.getHeight())
+    .setColor(1, 1, 1);
+
+  if (gestureMode)
+    g.setFont('Vector', 16)
+      .drawString('Button: Lap/Reset\nSwipe: Start/stop\nTap: Light', TEXT_MID, BAR_TOP);
+  else {
+    g.setFont('Vector', 24);
+    if (!state.wasRunning) {  //If the timer was never running:
+      if (storage.read('stlapview.app.js') !== undefined)         //If stlapview is installed, there should be a button to open it and a button to start the timer
+        g.drawLine(g.getWidth() / 2, BAR_TOP, g.getWidth() / 2, g.getHeight())
+          .drawString("Laps", TEXT_LEFT, BUTTON_Y)
+          .drawImage(BUTTON_ICONS.play, BUTTON_RIGHT, BUTTON_Y);
+      else g.drawImage(BUTTON_ICONS.play, BUTTON_MID, BUTTON_Y);  //Otherwise, only a button to start the timer
+    } else {                  //If the timer was running:
+      g.drawLine(g.getWidth() / 2, BAR_TOP, g.getWidth() / 2, g.getHeight());
+      if (state.running) {    //If it is running now, have a lap button and a pause button
+        g.drawString("LAP", TEXT_LEFT, BUTTON_Y)
+          .drawImage(BUTTON_ICONS.pause, BUTTON_RIGHT, BUTTON_Y);
+      } else {                //If it is not running now, have a reset button and a
+        g.drawImage(BUTTON_ICONS.reset, BUTTON_LEFT, BUTTON_Y)
+          .drawImage(BUTTON_ICONS.play, BUTTON_RIGHT, BUTTON_Y);
+      }
+    }
+  }
+}
+
+function drawTime() {
+  function pad(number) {
+    return ('00' + parseInt(number)).slice(-2);
+  }
+
+  let time = getTime();
+  g.reset(0, 0, 0)
+    .setFontAlign(0, 0)
+    .setFont("Vector", 36)
+    .clearRect(0, 24, g.getWidth(), g.getHeight() - 48)
+
+    //Draw the time
+    .drawString((() => {
+      let hours = Math.floor(time / 3600000);
+      let minutes = Math.floor((time % 3600000) / 60000);
+      let seconds = Math.floor((time % 60000) / 1000);
+      let hundredths = Math.floor((time % 1000) / 10);
+
+      if (hours >= 1) return `${hours}:${pad(minutes)}:${pad(seconds)}`;
+      else return `${minutes}:${pad(seconds)}:${pad(hundredths)}`;
+    })(), g.getWidth() / 2, g.getHeight() / 2);
+
+  //Draw the lap labels if necessary
+  if (lapHistory.splits.length >= 1) {
+    let lastLap = lapHistory.splits.length;
+    let curLap = lastLap + 1;
+
+    g.setFont("Vector", 12)
+      .drawString((() => {
+        let lapTime = time - lapHistory.splits[lastLap - 1];
+        let hours = Math.floor(lapTime / 3600000);
+        let minutes = Math.floor((lapTime % 3600000) / 60000);
+        let seconds = Math.floor((lapTime % 60000) / 1000);
+        let hundredths = Math.floor((lapTime % 1000) / 10);
+
+        if (hours == 0) return `Lap ${curLap}: ${pad(minutes)}:${pad(seconds)}:${pad(hundredths)}`;
+        else return `Lap ${curLap}: ${hours}:${pad(minutes)}:${pad(seconds)}:${pad(hundredths)}`;
+      })(), g.getWidth() / 2, g.getHeight() / 2 + 18)
+      .drawString((() => {
+        let lapTime;
+        if (lastLap == 1) lapTime = lapHistory.splits[lastLap - 1];
+        else lapTime = lapHistory.splits[lastLap - 1] - lapHistory.splits[lastLap - 2];
+        let hours = Math.floor(lapTime / 3600000);
+        let minutes = Math.floor((lapTime % 3600000) / 60000);
+        let seconds = Math.floor((lapTime % 60000) / 1000);
+        let hundredths = Math.floor((lapTime % 1000) / 10);
+
+        if (hours == 0) return `Lap ${lastLap}: ${pad(minutes)}:${pad(seconds)}:${pad(hundredths)}`;
+        else return `Lap ${lastLap}: ${hours}:${pad(minutes)}:${pad(seconds)}:${pad(hundredths)}`;
+      })(), g.getWidth() / 2, g.getHeight() / 2 + 30);
+  }
+}
+
+drawButtons();
+
+function firstTimeStart(now, time) {
+  state = {
+    wasRunning: true,
+    sessionStart: Math.floor(now),
+    running: true,
+    startTime: now,
+    pausedTime: 0,
+    elapsedTime: 0,
+  };
+  lapFile = 'stlap-' + state.sessionStart + '.json';
+  setupTimerInterval();
+  Bangle.buzz(200);
+  drawButtons();
+}
+
+function split(now, time) {
+  lapHistory.splits.push(time);
+  Bangle.buzz();
+}
+
+function pause(now, time) {
+  //Record the exact moment that we paused
+  state.pausedTime = now;
+
+  //Stop the timer
+  state.running = false;
+  stopTimerInterval();
+  Bangle.buzz(200);
+  drawTime();
+  drawButtons();
+}
+
+function reset(now, time) {
+  //Record the time
+  lapHistory.splits.push(time);
+  lapHistory.final = true;
+  storage.writeJSON(lapFile, lapHistory);
+
+  //Reset the timer
+  state = STATE_DEFAULT;
+  lapHistory = {
+    final: false,
+    splits: []
+  };
+  Bangle.buzz(500);
+  drawTime();
+  drawButtons();
+}
+
+function start(now, time) {
+  //Start the timer and record when we started
+  state.elapsedTime += (state.pausedTime - state.startTime);
+  state.startTime = now;
+  state.running = true;
+  setupTimerInterval();
+  Bangle.buzz(200);
+  drawTime();
+  drawButtons();
+}
+
+Bangle.on("touch", (button, xy) => {
+  //In gesture mode, just turn on the light and then return
+  if (gestureMode) {
+    Bangle.setLCDPower(true);
+    return;
+  }
+
+  //If we support full touch and we're not touching the keys, ignore.
+  //If we don't support full touch, we can't tell so just assume we are.
+  if (xy !== undefined && xy.y <= g.getHeight() - 48) return;
+
+  let now = (new Date()).getTime();
+  let time = getTime();
+
+  if (!state.wasRunning) {
+    if (storage.read('stlapview.app.js') !== undefined) {
+      //If we were never running and stlapview is installed, there are two buttons: open stlapview and start the timer
+      if (button == 1) load('stlapview.app.js');
+      else firstTimeStart(now, time);
+    }
+    //If stlapview there is only one button: the start button
+    else firstTimeStart(now, time);
+  } else if (state.running) {
+    //If we are running, there are two buttons: lap and pause
+    if (button == 1) split(now, time);
+    else pause(now, time);
+
+  } else {
+    //If we are stopped, there are two buttons: reset and continue
+    if (button == 1) reset(now, time);
+    else start(now, time);
+  }
+});
+
+Bangle.on('swipe', direction => {
+  let now = (new Date()).getTime();
+  let time = getTime();
+
+  if (gestureMode) {
+    Bangle.setLCDPower(true);
+    if (!state.wasRunning) firstTimeStart(now, time);
+    else if (state.running) pause(now, time);
+    else start(now, time);
+  } else {
+    gestureMode = true;
+    Bangle.setOptions({
+      lockTimeout: 0
+    });
+    drawTime();
+    drawButtons();
+  }
+});
+
+setWatch(() => {
+  let now = (new Date()).getTime();
+  let time = getTime();
+
+  if (gestureMode) {
+    Bangle.setLCDPower(true);
+    if (state.running) split(now, time);
+    else reset(now, time);
+  }
+}, BTN1, { repeat: true });
+
+let timerInterval;
+
+function setupTimerInterval() {
+  if (timerInterval !== undefined) {
+    clearInterval(timerInterval);
+  }
+  timerInterval = setInterval(drawTime, 10);
+}
+
+function stopTimerInterval() {
+  if (timerInterval !== undefined) {
+    clearInterval(timerInterval);
+    timerInterval = undefined;
+  }
+}
+
+drawTime();
+if (state.running) {
+  setupTimerInterval();
+}
+
+//Save our state when the app is closed
+E.on('kill', () => {
+  storage.writeJSON(STATE_PATH, state);
+  if (state.wasRunning) {
+    storage.writeJSON(lapFile, lapHistory);
+  }
+});
+
+Bangle.loadWidgets();
+Bangle.drawWidgets();
\ No newline at end of file
diff --git a/apps/stlap/icon.js b/apps/stlap/icon.js
new file mode 100644
index 000000000..eb0ec64c7
--- /dev/null
+++ b/apps/stlap/icon.js
@@ -0,0 +1 @@
+require("heatshrink").decompress(atob("mEwwcCpMkyQCCB4oLFAQsf/4AC/ARvIYM//hHNAQIRBBxgRliQECCJgHFCJWJA4slCJEiDI+UIhYCFAw1IJ5NSAwtECI8/yVKNBYCC/5uGyIRHp/8AoMpCJvkCIyMGAQN/CISSECI+T/6kHPQ+f/IFD0gRL5IRP/4RHag8n/zaHCI8/+QRQfxARHYQQRNYQYROWAQ1OAQwR4YpACFa5YRbxIRLkoGDCKORCJcpCKuSCJYGFogRJpQGFpARJqQJGiQRIDY7aIagYCFSQyMEAQxoLJRQLG"))
\ No newline at end of file
diff --git a/apps/stlap/icon.png b/apps/stlap/icon.png
new file mode 100644
index 000000000..6b9ba85bb
Binary files /dev/null and b/apps/stlap/icon.png differ
diff --git a/apps/stlap/img/pause.png b/apps/stlap/img/pause.png
new file mode 100644
index 000000000..ad31dadcf
Binary files /dev/null and b/apps/stlap/img/pause.png differ
diff --git a/apps/stlap/img/play.png b/apps/stlap/img/play.png
new file mode 100644
index 000000000..6c20c24c5
Binary files /dev/null and b/apps/stlap/img/play.png differ
diff --git a/apps/stlap/img/reset.png b/apps/stlap/img/reset.png
new file mode 100644
index 000000000..7a317d097
Binary files /dev/null and b/apps/stlap/img/reset.png differ
diff --git a/apps/stlap/metadata.json b/apps/stlap/metadata.json
new file mode 100644
index 000000000..1ecdc5b6e
--- /dev/null
+++ b/apps/stlap/metadata.json
@@ -0,0 +1,24 @@
+{
+  "id": "stlap",
+  "name": "Stopwatch",
+  "version": "0.03",
+  "description": "A stopwatch that remembers its state, with a lap timer and a gesture mode (enable by swiping)",
+  "icon": "icon.png",
+  "type": "app",
+  "tags": "tools,app",
+  "supports": [
+    "BANGLEJS2"
+  ],
+  "allow_emulator": true,
+  "storage": [
+    {
+      "name": "stlap.app.js",
+      "url": "app.js"
+    },
+    {
+      "name": "stlap.img",
+      "url": "icon.js",
+      "evaluate": true
+    }
+  ]
+}
\ No newline at end of file
diff --git a/apps/stlapview/ChangeLog b/apps/stlapview/ChangeLog
new file mode 100644
index 000000000..c819919ed
--- /dev/null
+++ b/apps/stlapview/ChangeLog
@@ -0,0 +1,2 @@
+0.01: New app!
+0.02: Submitted to the app loader
\ No newline at end of file
diff --git a/apps/stlapview/app.js b/apps/stlapview/app.js
new file mode 100644
index 000000000..7a58b5782
--- /dev/null
+++ b/apps/stlapview/app.js
@@ -0,0 +1,110 @@
+const storage = require("Storage");
+
+Bangle.loadWidgets();
+Bangle.drawWidgets();
+
+function pad(number) {
+  return ('00' + parseInt(number)).slice(-2);
+}
+
+function fileNameToDateString(fileName) {
+  let timestamp = 0;
+  let foundDigitYet = false;
+  for (let character of fileName) {
+    if ('1234567890'.includes(character)) {
+      foundDigitYet = true;
+      timestamp *= 10;
+      timestamp += parseInt(character);
+    } else if (foundDigitYet) break;
+  }
+  let date = new Date(timestamp);
+
+  let dayOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getDay()];
+  let completed = storage.readJSON(fileName).final;
+
+  return `${dayOfWeek} ${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}` + (completed ? '' : ' (running)');
+}
+
+function msToHumanReadable(ms) {
+
+  let hours = Math.floor(ms / 3600000);
+  let minutes = Math.floor((ms % 3600000) / 60000);
+  let seconds = Math.floor((ms % 60000) / 1000);
+  let hundredths = Math.floor((ms % 1000) / 10);
+
+  return `${hours}:${pad(minutes)}:${pad(seconds)}:${pad(hundredths)}`;
+}
+
+function view(fileName) {
+  let lapTimes = [];
+  let fileData = storage.readJSON(fileName).splits;
+  for (let i = 0; i < fileData.length; i++) {
+    if (i == 0) lapTimes.push(fileData[i]);
+    else lapTimes.push(fileData[i] - fileData[i - 1]);
+  }
+
+  let fastestIndex = 0;
+  let slowestIndex = 0;
+  for (let i = 0; i < lapTimes.length; i++) {
+    if (lapTimes[i] < lapTimes[fastestIndex]) fastestIndex = i;
+    else if (lapTimes[i] > lapTimes[slowestIndex]) slowestIndex = i;
+  }
+
+  let lapMenu = {
+    '': {
+      'title': fileNameToDateString(fileName),
+      'back': () => { E.showMenu(mainMenu); }
+    },
+  };
+  lapMenu[`Total time: ${msToHumanReadable(fileData[fileData.length - 1])}`] = () => { };
+  lapMenu[`Fastest lap: ${fastestIndex + 1}: ${msToHumanReadable(lapTimes[fastestIndex])}`] = () => { };
+  lapMenu[`Slowest lap: ${slowestIndex + 1}: ${msToHumanReadable(lapTimes[slowestIndex])}`] = () => { };
+  lapMenu[`Average lap: ${msToHumanReadable(fileData[fileData.length - 1] / fileData.length)}`] = () => { };
+
+  for (let i = 0; i < lapTimes.length; i++) {
+    lapMenu[`Lap ${i + 1}: ${msToHumanReadable(lapTimes[i])}`] = () => { };
+  }
+
+  lapMenu.Delete = () => {
+    E.showMenu({
+      '': {
+        'title': 'Are you sure?',
+        'back': () => { E.showMenu(lapMenu); }
+      },
+      'Yes': () => {
+        storage.erase(fileName);
+        showMainMenu();
+      },
+      'No': () => { E.showMenu(lapMenu); }
+    });
+  };
+
+  E.showMenu(lapMenu);
+}
+
+function showMainMenu() {
+  let LAP_FILES = storage.list(/stlap-[0-9]*\.json/);
+  LAP_FILES.sort();
+  LAP_FILES.reverse();
+
+  let mainMenu = {
+    '': {
+      'title': 'Sessions'
+    }
+  };
+
+  //I know eval is evil, but I can't think of any other way to do this.
+  for (let lapFile of LAP_FILES) {
+    mainMenu[fileNameToDateString(lapFile)] = eval(`(function() {
+      view('${lapFile}');
+    })`);
+  }
+
+  if (LAP_FILES.length == 0) {
+    mainMenu['No data'] = _ => { load(); };
+  }
+
+  E.showMenu(mainMenu);
+}
+
+showMainMenu();
\ No newline at end of file
diff --git a/apps/stlapview/icon.js b/apps/stlapview/icon.js
new file mode 100644
index 000000000..9e10b10a8
--- /dev/null
+++ b/apps/stlapview/icon.js
@@ -0,0 +1 @@
+require("heatshrink").decompress(atob("mEwwcCpMkyQCCB4oLFAQsf/4AC/ARvIYM//hHNAQIRBBxgRliQECCJgHFCJWJA4slCJEiDI+UIhYCFAw1IJ5NSAwtECI8/yVKNBOQYYMCpP/Nw2RAgYQBAAMP/gIBlIRHCAYAB8gRGRgVICIsESQwRCoANBA4OAAgIRGJgQRBSQWeCIck0gRFBYmf4AXDCI5BCkn/5ARGagSMBCIUn/xfBSQLaDCIiwD+SDBCJyVCCJrCCCJtPYQQROYQQ1OboYRKR4bdCCJChFCIShCCI7FDbooRCdJAXFa4wRBgAFBwARLIIIAFfY2JO4YAFNYMlQ4YRDkgSGCIuRAgaSBEAI7ChMpCJCzFgEBCImSCJEgCQPpBIlECI5NCjoJEpARISQMDPoYCBWAYCEL4V0BIjaDAQmeCI6SFAQMlkvACI5uGAYO4CJBKEBAWX//0yQ="))
\ No newline at end of file
diff --git a/apps/stlapview/icon.png b/apps/stlapview/icon.png
new file mode 100644
index 000000000..be24b8e6f
Binary files /dev/null and b/apps/stlapview/icon.png differ
diff --git a/apps/stlapview/metadata.json b/apps/stlapview/metadata.json
new file mode 100644
index 000000000..aa54a7b67
--- /dev/null
+++ b/apps/stlapview/metadata.json
@@ -0,0 +1,27 @@
+{
+  "id": "stlapview",
+  "name": "Stopwatch laps",
+  "version": "0.02",
+  "description": "Optional lap viewer for my stopwatch app",
+  "icon": "icon.png",
+  "type": "app",
+  "tags": "tools,app",
+  "supports": [
+    "BANGLEJS2"
+  ],
+  "allow_emulator": true,
+  "storage": [
+    {
+      "name": "stlapview.app.js",
+      "url": "app.js"
+    },
+    {
+      "name": "stlapview.img",
+      "url": "icon.js",
+      "evaluate": true
+    }
+  ],
+  "dependencies": {
+    "stlap": "app"
+  }
+}
\ No newline at end of file
diff --git a/apps/swiperclocklaunch/ChangeLog b/apps/swiperclocklaunch/ChangeLog
index 244b602b5..e7ad4555c 100644
--- a/apps/swiperclocklaunch/ChangeLog
+++ b/apps/swiperclocklaunch/ChangeLog
@@ -1,3 +1,4 @@
 0.01: New App!
 0.02: Fix issue with mode being undefined
 0.03: Update setUI to work with new Bangle.js 2v13 menu style
+0.04: Update to work with new 'fast switch' clock->launcher functionality
diff --git a/apps/swiperclocklaunch/boot.js b/apps/swiperclocklaunch/boot.js
index bb285ea94..ea00a6735 100644
--- a/apps/swiperclocklaunch/boot.js
+++ b/apps/swiperclocklaunch/boot.js
@@ -1,19 +1,19 @@
-// clock -> launcher
 (function() {
-    var sui = Bangle.setUI;
-    Bangle.setUI = function(mode, cb) {
-        sui(mode,cb);
-        if(!mode) return;
-        if ("object"==typeof mode) mode = mode.mode;
-        if (!mode.startsWith("clock")) return;
-        Bangle.swipeHandler = dir => { if (dir<0) Bangle.showLauncher(); };
-        Bangle.on("swipe", Bangle.swipeHandler);
-    };
-})();
-// launcher -> clock
-setTimeout(function() {  
-    if (global.__FILE__ && __FILE__.endsWith(".app.js") && (require("Storage").readJSON(__FILE__.slice(0,-6)+"info",1)||{}).type=="launch") {
+  var sui = Bangle.setUI;
+  Bangle.setUI = function(mode, cb) {
+    sui(mode,cb);
+    if(!mode) return;
+    if ("object"==typeof mode) mode = mode.mode;
+    if (mode.startsWith("clock")) {
+      // clock -> launcher
+      Bangle.swipeHandler = dir => { if (dir<0) Bangle.showLauncher(); };
+      Bangle.on("swipe", Bangle.swipeHandler);
+    } else {
+      if (global.__FILE__ && __FILE__.endsWith(".app.js") && (require("Storage").readJSON(__FILE__.slice(0,-6)+"info",1)||{}).type=="launch") {
+        // launcher -> clock
         Bangle.swipeHandler = dir => { if (dir>0) load(); };
         Bangle.on("swipe", Bangle.swipeHandler);
+      }
     }
-}, 10);
+  };
+})();
diff --git a/apps/swiperclocklaunch/metadata.json b/apps/swiperclocklaunch/metadata.json
index 5e4a0d648..4f27da528 100644
--- a/apps/swiperclocklaunch/metadata.json
+++ b/apps/swiperclocklaunch/metadata.json
@@ -1,7 +1,7 @@
 {
   "id": "swiperclocklaunch",
   "name": "Swiper Clock Launch",
-  "version": "0.03",
+  "version": "0.04",
   "description": "Navigate between clock and launcher with Swipe action",
   "icon": "swiperclocklaunch.png",
   "type": "bootloader",
diff --git a/apps/torch/ChangeLog b/apps/torch/ChangeLog
index fd904b6e8..27d880e78 100644
--- a/apps/torch/ChangeLog
+++ b/apps/torch/ChangeLog
@@ -3,3 +3,6 @@
 0.03: Add Color Changing Settings
 0.04: Add Support For Bangle.js 2
 0.05: Default full Brightness
+0.06: Press upper left corner to exit on Bangle.js 2
+0.07: Code tweaks
+0.08: Force background of widget field to the torch colour
diff --git a/apps/torch/README.md b/apps/torch/README.md
new file mode 100644
index 000000000..e06861071
--- /dev/null
+++ b/apps/torch/README.md
@@ -0,0 +1,2 @@
+On Bangle.js 2, pressing the upper left corner where the red back button would be exits the app if the screen is unlocked.
+
diff --git a/apps/torch/app.js b/apps/torch/app.js
index 2af35fdb6..e3f1ab89c 100644
--- a/apps/torch/app.js
+++ b/apps/torch/app.js
@@ -11,10 +11,15 @@ Bangle.setLCDBrightness(1);
 Bangle.setLCDPower(1);
 Bangle.setLCDTimeout(0);
 g.reset();
+g.setTheme({bg:settings.bg,fg:"#000"});
 g.setColor(settings.bg);
 g.fillRect(0,0,g.getWidth(),g.getHeight());
-// Any button turns off
-setWatch(()=>load(), BTN1);
-if (global.BTN2) setWatch(()=>load(), BTN2);
-if (global.BTN3) setWatch(()=>load(), BTN3);
-
+Bangle.setUI({
+  mode : 'custom',
+  back : load, // B2: Clicking the hardware button or pressing upper left corner turns off (where red back button would be)
+  btn : (n)=>{ // B1: Any button turns off
+    if (process.env.HWVERSION==1 && (n==1 || n==2 || n==3)) {
+      load();
+    }
+  }
+});
diff --git a/apps/torch/metadata.json b/apps/torch/metadata.json
index af85370ac..aff8dee37 100644
--- a/apps/torch/metadata.json
+++ b/apps/torch/metadata.json
@@ -2,7 +2,7 @@
   "id": "torch",
   "name": "Torch",
   "shortName": "Torch",
-  "version": "0.05",
+  "version": "0.08",
   "description": "Turns screen white to help you see in the dark. Select from the launcher or press BTN1,BTN3,BTN1,BTN3 quickly to start when in any app that shows widgets on Bangle.js 1. You can also set the color through the app's setting menu.",
   "icon": "app.png",
   "tags": "tool,torch",
diff --git a/apps/wid_edit/ChangeLog b/apps/wid_edit/ChangeLog
index 2fa857bd8..d8e165029 100644
--- a/apps/wid_edit/ChangeLog
+++ b/apps/wid_edit/ChangeLog
@@ -1 +1,4 @@
-0.01: new Widget Editor!
\ No newline at end of file
+0.01: new Widget Editor!
+0.02: Wrap loadWidgets instead of replacing to keep original functionality intact
+      Change back entry to menu option
+      Allow changing widgets into all areas, including bottom widget bar
diff --git a/apps/wid_edit/boot.js b/apps/wid_edit/boot.js
index 872965c97..3cb545a34 100644
--- a/apps/wid_edit/boot.js
+++ b/apps/wid_edit/boot.js
@@ -1,24 +1,20 @@
-Bangle.loadWidgets = function() {
-  global.WIDGETS={};
-  require("Storage").list(/\.wid\.js$/)
-    .forEach(w=>{
-      try { eval(require("Storage").read(w)); }
-      catch (e) { print(w, e); }
-    });
-  const s = require("Storage").readJSON("wid_edit.json", 1) || {},
-    c = s.custom || {};
+Bangle.loadWidgets = (o => ()=>{
+  o();
+  const s = require("Storage").readJSON("wid_edit.json", 1) || {};
+  const c = s.custom || {};
   for (const w in c){
     if (!(w in WIDGETS)) continue; // widget no longer exists
     // store defaults of customized values in _WIDGETS
-    global._WIDGETS=global._WIDGETS||{};
-    _WIDGETS[w] = {};
-    Object.keys(c[w]).forEach(k => _WIDGETS[w][k] = WIDGETS[w][k]);
-    Object.assign(WIDGETS[w], c[w]);
+    if (!global._WIDGETS) global._WIDGETS = {};
+    if (!global._WIDGETS[w]) global._WIDGETS[w] = {};
+    Object.keys(c[w]).forEach(k => global._WIDGETS[w][k] = global.WIDGETS[w][k]);
+    //overide values in widget with configured ones
+    Object.assign(global.WIDGETS[w], c[w]);
   }
-  const W = WIDGETS;
-  WIDGETS = {};
+  const W = global.WIDGETS;
+  global.WIDGETS = {};
   Object.keys(W)
     .sort()
     .sort((a, b) => (0|W[b].sortorder)-(0|W[a].sortorder))
-    .forEach(k => WIDGETS[k] = W[k]);
-}
\ No newline at end of file
+    .forEach(k => global.WIDGETS[k] = W[k]);
+})(Bangle.loadWidgets);
diff --git a/apps/wid_edit/metadata.json b/apps/wid_edit/metadata.json
index 66d0192f6..d963a53d0 100644
--- a/apps/wid_edit/metadata.json
+++ b/apps/wid_edit/metadata.json
@@ -1,6 +1,6 @@
 {
   "id": "wid_edit",
-  "version": "0.01",
+  "version": "0.02",
   "name": "Widget Editor",
   "icon": "icon.png",
   "description": "Customize widget locations",
diff --git a/apps/wid_edit/settings.js b/apps/wid_edit/settings.js
index 0969ed533..1d34ae0ca 100644
--- a/apps/wid_edit/settings.js
+++ b/apps/wid_edit/settings.js
@@ -9,7 +9,7 @@
 
   let cleanup = false;
   for (const id in settings.custom) {
-    if (!(id in WIDGETS)) {
+    if (!(id in global.WIDGETS)) {
       // widget which no longer exists
       cleanup = true;
       delete settings.custom[id];
@@ -24,12 +24,12 @@
    * Sort & redraw all widgets
    */
   function redrawWidgets() {
-    let W = WIDGETS;
+    let W = global.WIDGETS;
     global.WIDGETS = {};
     Object.keys(W)
       .sort()
       .sort((a, b) => (0|W[b].sortorder)-(0|W[a].sortorder))
-      .forEach(k => {WIDGETS[k] = W[k]});
+      .forEach(k => {global.WIDGETS[k] = W[k];});
     Bangle.drawWidgets();
   }
 
@@ -52,9 +52,9 @@
   }
 
   function edit(id) {
-    let WIDGET = WIDGETS[id],
+    let WIDGET = global.WIDGETS[id],
       def = {area: WIDGET.area, sortorder: WIDGET.sortorder|0}; // default values
-    Object.assign(def, _WIDGETS[id]||{}); // defaults were saved in _WIDGETS
+    Object.assign(def, global._WIDGETS[id]||{}); // defaults were saved in _WIDGETS
 
     settings.custom = settings.custom||{};
     let saved = settings.custom[id] || {},
@@ -102,7 +102,7 @@
         settings.custom = settings.custom || {};
         settings.custom[id] = saved;
       } else if (settings.custom) {
-         delete settings.custom[id]
+         delete settings.custom[id];
       }
       if (!Object.keys(settings.custom).length) delete settings.custom;
       require("Storage").writeJSON("wid_edit.json", settings);
@@ -112,8 +112,8 @@
       let _W = {};
       if (saved.area) _W.area = def.area;
       if ('sortorder' in saved) _W.sortorder = def.sortorder;
-      if (Object.keys(_W).length) _WIDGETS[id] = _W;
-      else delete _WIDGETS[id];
+      if (Object.keys(_W).length) global._WIDGETS[id] = _W;
+      else delete global._WIDGETS[id];
 
       // drawWidgets won't clear e.g. bottom bar if we just disabled the last bottom widget
       redrawWidgets();
@@ -122,17 +122,23 @@
       m.draw();
     }
 
+    const AREA_NAMES = [ "Top left", "Top right", "Bottom left", "Bottom right" ];
+    const AREAS = [ "tl", "tr", "bl", "br" ];
+
     const menu = {
-      "": {"title": name(id)},
-      /*LANG*/"< Back": () => {
-        redrawWidgets();
-        mainMenu();
-      },
-      /*LANG*/"Side": {
-        value: (area === 'tl'),
-        format: tl => tl ? /*LANG*/"Left" : /*LANG*/"Right",
-        onchange: tl => {
-          area = tl ? "tl" : "tr";
+      "": {"title": name(id),
+        back: () => {
+          redrawWidgets();
+          mainMenu();
+        } },
+      /*LANG*/"Position": {
+        value: AREAS.indexOf(area),
+        format: v => AREA_NAMES[v],
+        min: 0,
+        max: AREAS.length - 1,
+        onchange: v => {
+          print("v", v);
+          area = AREAS[v];
           save();
         }
       },
@@ -149,7 +155,7 @@
         save();
         mainMenu(); // changing multiple values made the rest of the menu wrong, so take the easy out
       }
-    }
+    };
 
     let m = E.showMenu(menu);
   }
@@ -157,31 +163,32 @@
 
   function mainMenu() {
     let menu = {
-      "": {"title": /*LANG*/"Widgets"},
+      "": {
+        "title": /*LANG*/"Widgets",
+        back: ()=>{
+          if (!Object.keys(global._WIDGETS).length) delete global._WIDGETS; // no defaults to remember
+          back();
+        }},
     };
-    menu[/*LANG*/"< Back"] = ()=>{
-      if (!Object.keys(_WIDGETS).length) delete _WIDGETS; // no defaults to remember
-      back();
-    };
-    Object.keys(WIDGETS).forEach(id=>{
+    Object.keys(global.WIDGETS).forEach(id=>{
       // mark customized widgets with asterisk
-      menu[name(id)+((id in _WIDGETS) ? " *" : "")] = () => edit(id);
+      menu[name(id)+((id in global._WIDGETS) ? " *" : "")] = () => edit(id);
     });
-    if (Object.keys(_WIDGETS).length) { // only show reset if there is anything to reset
+    if (Object.keys(global._WIDGETS).length) { // only show reset if there is anything to reset
       menu[/*LANG*/"Reset All"] = () => {
         E.showPrompt(/*LANG*/"Reset all widgets?").then(confirm => {
           if (confirm) {
             delete settings.custom;
             require("Storage").writeJSON("wid_edit.json", settings);
-            for(let id in _WIDGETS) {
-              Object.assign(WIDGETS[id], _WIDGETS[id]) // restore defaults
+            for(let id in global._WIDGETS) {
+              Object.assign(global.WIDGETS[id], global._WIDGETS[id]); // restore defaults
             }
             global._WIDGETS = {};
             redrawWidgets();
           }
           mainMenu(); // reload with reset widgets
-        })
-      }
+        });
+      };
     }
 
     E.showMenu(menu);
diff --git a/apps/widbars/ChangeLog b/apps/widbars/ChangeLog
index 61e28e6e4..0065c1b27 100644
--- a/apps/widbars/ChangeLog
+++ b/apps/widbars/ChangeLog
@@ -1,3 +1,4 @@
 0.01: New Widget!
 0.02: Battery bar turns yellow on charge
       Memory status bar does not trigger garbage collect
+0.03: Set battery color on load
diff --git a/apps/widbars/metadata.json b/apps/widbars/metadata.json
index a9981305c..06965e77e 100644
--- a/apps/widbars/metadata.json
+++ b/apps/widbars/metadata.json
@@ -1,7 +1,7 @@
 {
   "id": "widbars",
   "name": "Bars Widget",
-  "version": "0.02",
+  "version": "0.03",
   "description": "Display several measurements as vertical bars.",
   "icon": "icon.png",
   "screenshots": [{"url":"screenshot.png"}],
diff --git a/apps/widbars/widget.js b/apps/widbars/widget.js
index cceeb0897..0a4bba76c 100644
--- a/apps/widbars/widget.js
+++ b/apps/widbars/widget.js
@@ -42,7 +42,7 @@
     if (top) g             .clearRect(x,y,     x+w-1,y+top-1); // erase above bar
     if (f)   g.setColor(col).fillRect(x,y+top, x+w-1,y+h-1);   // even for f=0.001 this is still 1 pixel high
   }
-  let batColor='#0f0';
+  let batColor=Bangle.isCharging()?'#ff0':'#0f0';
   function draw() {
     g.reset();
     const x = this.x, y = this.y,
diff --git a/apps/widmeda/ChangeLog b/apps/widmeda/ChangeLog
index 7415150e6..6ac092a85 100644
--- a/apps/widmeda/ChangeLog
+++ b/apps/widmeda/ChangeLog
@@ -1 +1,2 @@
 0.01: Initial Medical Alert Widget!
+0.02: Use Medical Information app for medical alert text, and to display details 
diff --git a/apps/widmeda/README.md b/apps/widmeda/README.md
index 0bbfc4dc3..151b11058 100644
--- a/apps/widmeda/README.md
+++ b/apps/widmeda/README.md
@@ -11,12 +11,12 @@ Implemented:
 - Basic medical alert logo and message
 - Only display bottom widget on clocks
 - High contrast colours depending on theme
+- Configure medical alert text (using Medical Information app)
+- Show details when touched (using Medical Information app)
 
 Future:
 
 - Configure when to show bottom widget (always/never/clocks)
-- Configure medical alert text
-- Show details when touched
 
 ## Creator
 
diff --git a/apps/widmeda/metadata.json b/apps/widmeda/metadata.json
index 346306b8e..8ce051dd4 100644
--- a/apps/widmeda/metadata.json
+++ b/apps/widmeda/metadata.json
@@ -1,10 +1,11 @@
 { "id": "widmeda",
   "name": "Medical Alert Widget",
   "shortName":"Medical Alert",
-  "version":"0.01",
+  "version":"0.02",
   "description": "Display a medical alert in the bottom widget section.",
   "icon": "widget.png",  
   "type": "widget",
+  "dependencies" : { "medicalinfo":"app" },
   "tags": "health,medical,tools,widget",
   "supports" : ["BANGLEJS2"],
   "readme": "README.md",
diff --git a/apps/widmeda/widget.js b/apps/widmeda/widget.js
index a2d109596..2758ae11f 100644
--- a/apps/widmeda/widget.js
+++ b/apps/widmeda/widget.js
@@ -1,30 +1,47 @@
 (() => {
+  function getAlertText() {
+    const medicalinfo = require("medicalinfo").load();
+    const alertText = ((Array.isArray(medicalinfo.medicalAlert)) && (medicalinfo.medicalAlert[0])) ? medicalinfo.medicalAlert[0] : "";
+    return (g.wrapString(alertText, g.getWidth()).length === 1) ? alertText : "MEDICAL ALERT";
+  }
+
   // Top right star of life logo
-  WIDGETS["widmedatr"]={
+  WIDGETS["widmedatr"] = {
     area: "tr",
     width: 24,
-    draw: function() {
+    draw: function () {
       g.reset();
       g.setColor("#f00");
       g.drawImage(atob("FhYBAAAAA/AAD8AAPwAc/OD/P8P8/x/z/n+/+P5/wP58A/nwP5/x/v/n/P+P8/w/z/Bz84APwAA/AAD8AAAAAA=="), this.x + 1, this.y + 1);
     }
   };
 
-  // Bottom medical alert message
-  WIDGETS["widmedabl"]={
+  // Bottom medical alert text
+  WIDGETS["widmedabl"] = {
     area: "bl",
-    width: Bangle.CLOCK?Bangle.appRect.w:0,
-    draw: function() {
+    width: Bangle.CLOCK ? Bangle.appRect.w : 0,
+    draw: function () {
       // Only show the widget on clocks
       if (!Bangle.CLOCK) return;
 
       g.reset();
       g.setBgColor(g.theme.dark ? "#fff" : "#f00");
       g.setColor(g.theme.dark ? "#f00" : "#fff");
-      g.setFont("Vector",18);
-      g.setFontAlign(0,0);
+      g.setFont("Vector", 16);
+      g.setFontAlign(0, 0);
       g.clearRect(this.x, this.y, this.x + this.width - 1, this.y + 23);
-      g.drawString("MEDICAL ALERT", this.width / 2, this.y + ( 23 / 2 ));
+
+      const alertText = getAlertText();
+      g.drawString(alertText, this.width / 2, this.y + (23 / 2));
     }
   };
+
+  Bangle.on("touch", (_, c) => {
+    const bl = WIDGETS.widmedabl;
+    const tr = WIDGETS.widmedatr;
+    if ((bl && c.x >= bl.x && c.x < bl.x + bl.width && c.y >= bl.y && c.y <= bl.y + 24)
+      || (tr && c.x >= tr.x && c.x < tr.x + tr.width && c.y >= tr.y && c.y <= tr.y + 24)) {
+      load("medicalinfo.app.js");
+    }
+  });
 })();
diff --git a/bin/firmwaremaker_c.js b/bin/firmwaremaker_c.js
index 87065dab4..e56e28ad9 100755
--- a/bin/firmwaremaker_c.js
+++ b/bin/firmwaremaker_c.js
@@ -172,7 +172,7 @@ Promise.all(APPS.map(appid => {
 // Generated by BangleApps/bin/build_bangles_c.js
 
 const int jsfStorageInitialContentLength = ${storageContent.length};
-const char jsfStorageInitialContents[] = {
+const unsigned char jsfStorageInitialContents[] = {
 `;
 for (var i=0;i