diff --git a/apps.json b/apps.json index 156fdc4cc..4072dc84d 100644 --- a/apps.json +++ b/apps.json @@ -5081,10 +5081,10 @@ { "id": "circlesclock", "name": "Circles clock", "shortName":"Circles clock", - "version":"0.03", + "version":"0.04", "description": "A clock with circles for different data at the bottom in a probably familiar style", "icon": "app.png", - "screenshots": [{"url":"screenshot.png"}], + "screenshots": [{"url":"screenshot-dark.png"}, {"url":"screenshot-light.png"}], "dependencies": {"widpedom":"app"}, "type": "clock", "tags": "clock", diff --git a/apps/circlesclock/ChangeLog b/apps/circlesclock/ChangeLog index c0aa4e2f8..ca1da6e21 100644 --- a/apps/circlesclock/ChangeLog +++ b/apps/circlesclock/ChangeLog @@ -1,3 +1,7 @@ 0.01: New clock 0.02: Fix icon & add battery warn functionality 0.03: Theming support & minor fixes +0.04: Make configurable what to show in each circle + Add step distance and weather + Allow switching visibility of widgets + Make circles and text slightly bigger diff --git a/apps/circlesclock/README.md b/apps/circlesclock/README.md index 66d9afe08..9aaa4bc8e 100644 --- a/apps/circlesclock/README.md +++ b/apps/circlesclock/README.md @@ -2,19 +2,22 @@ A clock with circles for different data at the bottom in a probably familiar style -It shows besides time, date and day of week the following information: +By default the time, date and day of week is shown. + +It can show the following information (this can be configured): * Steps (requires [pedometer widget](https://banglejs.com/apps/#pedometer)) - * Heart rate (when screen is on and unlocked) - * Battery (including charging and battery low) + * Steps distance (depending on steps) + * Heart rate (automatically updates when screen is on and unlocked) + * Battery (including charging status and battery low warning) + * Weather (requires [weather app](https://banglejs.com/apps/#weather)) -## Screenshot +## Screenshots +![Screenshot dark theme](screenshot-dark.png) +![Screenshot light theme](screenshot-light.png) -![Screenshot](screenshot.png) - -## TODO -* Show weather information -* Configure which information to show in each circle -* Configure visibility of widgets +# TODO +* Add sunrise and sunset +* Display moon instead of sun during night on weather circle ## Creator Marco ([myxor](https://github.com/myxor)) diff --git a/apps/circlesclock/app.js b/apps/circlesclock/app.js index 026b47cc6..91d4937c4 100644 --- a/apps/circlesclock/app.js +++ b/apps/circlesclock/app.js @@ -1,19 +1,37 @@ const locale = require("locale"); const heatshrink = require("heatshrink"); +const storage = require("Storage"); const shoesIcon = heatshrink.decompress(atob("h0OwYJGgmAAgUBkgECgVJB4cSoAUDyEBkARDpADBhMAyQRBgVAkgmDhIUDAAuQAgY1DAAYA=")); +const shoesIconGreen = heatshrink.decompress(atob("h0OwYJGhIEDgVIAgUEyQKDkmACgcggVACIeQAYMSgIRCgmApIbDiQUDAAkBkAFDGoYAD")); const heartIcon = heatshrink.decompress(atob("h0OwYOLkmQhMkgACByVJgESpIFBpEEBAIFBCgIFCCgsABwcAgQOCAAMSpAwDyBNM")); const powerIcon = heatshrink.decompress(atob("h0OwYQNsAED7AEDmwEDtu2AgUbtuABwXbBIUN23AAoYOCgEDFIgODABI")); const powerIconGreen = heatshrink.decompress(atob("h0OwYQNkAEDpAEDiQEDkmSAgUJkmABwVJBIUEyVAAoYOCgEBFIgODABI")); const powerIconRed = heatshrink.decompress(atob("h0OwYQNoAEDyAEDkgEDpIFDiVJBweSAgUJkmAAoYZDgQpEBwYAJA")); +const weatherCloudy = heatshrink.decompress(atob("iEQwYWTgP//+AAoMPAoPwAoN/AocfAgP//0AAgQAB/AFEABgdDAAMDDohMRA")); +const weatherSunny = heatshrink.decompress(atob("iEQwYLIg3AAgVgAQMMAo8Am3YAgUB23bAoUNAoIUBjYFCsOwBYoFDDpFgHYI1JI4gFGAAYA=")); +const weatherPartlyCloudy = heatshrink.decompress(atob("iEQwYQNv0AjgGDn4EDh///gFChwREC4MfxwIBv0//+AC4X4j4FCv/AgfwgED/wIBuAaBBwgFDgP4gf/AAXABwIEBDQQAEA==")); +const weatherRainy = heatshrink.decompress(atob("iEQwYLIg/gAgUB///wAFBh/AgfwgED/wIBuEAj4OCv0AjgaCh/4AocAnAFBFIU4EAM//gRBEAIOBhw1C/AmDAosAC4JNIAAg")); +const weatherPartlyRainy = heatshrink.decompress(atob("h0OwYJGjkAnAFCj+AAgU//4FCuEA8EAg8ch/4gEB4////AAoIIBCIMD/wgCg4bBg/8BwMD+AgBh4ZBDQf/FIIABh4IBgAA==")); +const weatherSnowy = heatshrink.decompress(atob("iEQwYROn/8AocH8AECuAFBh0Agf+CIN/4EDx/4j/x4EAgIIBwAXBAogRFDoopFGoxBGABIA=")); +const weatherFoggy = heatshrink.decompress(atob("iEQwYROn/8AgUB/EfwAFBh/AgfwgED/wIBuEABwd/4EcDQgFDgE4Fosf///8f//A/Lj/xCQIRNA=")); +const weatherStormy = heatshrink.decompress(atob("iEQwYLIg/gAgUB///wAFBh/AgfwgED/wIBuEAj4OCv0AjgaCh/4AoX8gE4AoQpBnAdBF4IRBDQMH/kOHgY7DAo4AOA==")); + let settings; function loadSettings() { - settings = require("Storage").readJSON("circlesclock.json", 1) || { + settings = storage.readJSON("circlesclock.json", 1) || { + 'minHR': 40, 'maxHR': 200, 'stepGoal': 10000, - 'batteryWarn': 30 + 'stepDistanceGoal': 8000, + 'stepLength': 0.8, + 'batteryWarn': 30, + 'showWidgets': false, + 'circle1': 'hr', + 'circle2': 'steps', + 'circle3': 'battery' }; // Load step goal from pedometer widget as fallback if (settings.stepGoal == undefined) { @@ -21,122 +39,227 @@ function loadSettings() { settings.stepGoal = d != undefined && d.settings != undefined ? d.settings.goal : 10000; } } +loadSettings(); +const showWidgets = settings.showWidgets || false; +let hrtValue; + +// layout values: const colorFg = g.theme.dark ? '#fff' : '#000'; const colorBg = g.theme.dark ? '#000' : '#fff'; const colorGrey = '#808080'; const colorRed = '#ff0000'; -const colorGreen = '#00ff00'; - -let hrtValue; - -const h = g.getHeight(); +const colorGreen = '#008000'; +const colorBlue = '#0000ff'; +const colorYellow = '#ffff00'; +const widgetOffset = showWidgets ? 24 : 0; +const h = g.getHeight() - widgetOffset; const w = g.getWidth(); -const hOffset = 30; +const hOffset = 30 - widgetOffset; const h1 = Math.round(1 * h / 5 - hOffset); const h2 = Math.round(3 * h / 5 - hOffset); -const h3 = Math.round(8 * h / 8 - hOffset); -const w1 = Math.round(w / 6); -const w2 = Math.round(3 * w / 6); -const w3 = Math.round(5 * w / 6); -const radiusOuter = 22; -const radiusInner = 16; +const h3 = Math.round(8 * h / 8 - hOffset - 3); // circle y position +const circlePosX = [Math.round(w / 6), Math.round(3 * w / 6), Math.round(5 * w / 6)]; // cirle x positions +const radiusOuter = 25; +const radiusInner = 20; +const circleFont = "Vector:15"; +const circleFontBig = "Vector:16"; +const circleFontSmall = "Vector:13"; function draw() { - g.reset(); + g.clear(true); + + if (!showWidgets) { + /* + * 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. + */ + if (WIDGETS && typeof WIDGETS === "object") { + for (let wd of WIDGETS) { + wd.draw = () => {}; + wd.area = ""; + } + } + } else { + Bangle.drawWidgets(); + } + g.setColor(colorBg); - g.fillRect(0, 0, w, h); + g.fillRect(0, widgetOffset, w, h); // time g.setFont("Vector:50"); - g.setFontAlign(-1, -1); + g.setFontAlign(0, -1); g.setColor(colorFg); - g.drawString(locale.time(new Date(), 1), w / 10, h1 + 8); + g.drawString(locale.time(new Date(), 1), w / 2, h1 + 8); // date & dow - g.setFont("Vector:20"); + g.setFont("Vector:21"); g.setFontAlign(-1, 0); - g.drawString(locale.date(new Date()), w / 10, h2); - g.drawString(locale.dow(new Date()), w / 10, h2 + 22); + g.drawString(locale.date(new Date()), w > 180 ? 2 * w / 10 : w / 10, h2); + g.drawString(locale.dow(new Date()), w > 180 ? 2 * w / 10 : w / 10, h2 + 22); - // Steps circle - drawSteps(); - - // Heart circle - drawHeartRate(); - - // Battery circle - drawBattery(); + drawCircle(1); + drawCircle(2); + drawCircle(3); } +const defaultCircleTypes = ["steps", "hr", "battery"]; +function drawCircle(index) { + let type = settings['circle' + index]; + if (!type) type = defaultCircleTypes[index - 1]; + const w = getCirclePosition(type); + switch (type) { + case "steps": + drawSteps(w); + break; + case "stepsDist": + drawStepsDistance(w); + break; + case "hr": + drawHeartRate(w); + break; + case "battery": + drawBattery(w); + break; + case "weather": + drawWeather(w); + break; + } +} -function drawSteps() { +function getCirclePosition(type) { + for (let i = 1; i <= 3; i++) { + const setting = settings['circle' + i]; + if (setting == type) return circlePosX[i - 1]; + } + for (let i = 0; i < defaultCircleTypes.length; i++) { + if (type == defaultCircleTypes[i]) return circlePosX[i]; + } + return undefined; +} + +function isCircleEnabled(type) { + return getCirclePosition(type) != undefined; +} + +function drawSteps(w) { + if (!w) w = getCirclePosition("steps"); const steps = getSteps(); - const blue = '#0000ff'; + + // Draw rectangle background: + g.setColor(colorBg); + g.fillRect(w - radiusOuter - 3, h3 - radiusOuter - 3, w + radiusOuter + 3, h3 + radiusOuter + 3); + g.setColor(colorGrey); - g.fillCircle(w1, h3, radiusOuter); + g.fillCircle(w, h3, radiusOuter); const stepGoal = settings.stepGoal || 10000; if (stepGoal > 0) { let percent = steps / stepGoal; if (stepGoal < steps) percent = 1; - drawGauge(w1, h3, percent, blue); + drawGauge(w, h3, percent, colorBlue); } g.setColor(colorBg); - g.fillCircle(w1, h3, radiusInner); + g.fillCircle(w, h3, radiusInner); - g.fillPoly([w1, h3, w1 - 15, h3 + radiusOuter + 5, w1 + 15, h3 + radiusOuter + 5]); + g.fillPoly([w, h3, w - 15, h3 + radiusOuter + 5, w + 15, h3 + radiusOuter + 5]); - g.setFont("Vector:12"); + g.setFont(circleFont); g.setFontAlign(0, 0); g.setColor(colorFg); - g.drawString(shortValue(steps), w1 + 2, h3); + g.drawString(shortValue(steps), w + 2, h3); - g.drawImage(shoesIcon, w1 - 6, h3 + radiusOuter - 6); + g.drawImage(shoesIcon, w - 6, h3 + radiusOuter - 6); } -function drawHeartRate() { +function drawStepsDistance(w) { + if (!w) w = getCirclePosition("steps"); + const steps = getSteps(); + const stepDistance = settings.stepLength || 0.8; + const stepsDistance = Math.round(steps * stepDistance); + + // Draw rectangle background: + g.setColor(colorBg); + g.fillRect(w - radiusOuter - 3, h3 - radiusOuter - 3, w + radiusOuter + 3, h3 + radiusOuter + 3); + g.setColor(colorGrey); - g.fillCircle(w2, h3, radiusOuter); + g.fillCircle(w, h3, radiusOuter); + + const stepDistanceGoal = settings.stepDistanceGoal || 8000; + if (stepDistanceGoal > 0) { + let percent = stepsDistance / stepDistanceGoal; + if (stepDistanceGoal < stepsDistance) percent = 1; + drawGauge(w, h3, percent, colorGreen); + } + + g.setColor(colorBg); + g.fillCircle(w, h3, radiusInner); + + g.fillPoly([w, h3, w - 15, h3 + radiusOuter + 5, w + 15, h3 + radiusOuter + 5]); + + g.setFont(circleFont); + g.setFontAlign(0, 0); + g.setColor(colorFg); + g.drawString(shortValue(stepsDistance), w + 2, h3); + + g.drawImage(shoesIconGreen, w - 6, h3 + radiusOuter - 6); +} + +function drawHeartRate(w) { + if (!w) w = getCirclePosition("hr"); + + // Draw rectangle background: + g.setColor(colorBg); + g.fillRect(w - radiusOuter - 3, h3 - radiusOuter - 3, w + radiusOuter + 3, h3 + radiusOuter + 3); + + g.setColor(colorGrey); + g.fillCircle(w, h3, radiusOuter); if (hrtValue != undefined && hrtValue > 0) { - const minHR = 40; + const minHR = settings.minHR || 40; const percent = (hrtValue - minHR) / (settings.maxHR - minHR); - drawGauge(w2, h3, percent, colorRed); + drawGauge(w, h3, percent, colorRed); } g.setColor(colorBg); - g.fillCircle(w2, h3, radiusInner); + g.fillCircle(w, h3, radiusInner); - g.fillPoly([w2, h3, w2 - 15, h3 + radiusOuter + 5, w2 + 15, h3 + radiusOuter + 5]); + g.fillPoly([w, h3, w - 15, h3 + radiusOuter + 5, w + 15, h3 + radiusOuter + 5]); - g.setFont("Vector:12"); + g.setFont(circleFontBig); g.setFontAlign(0, 0); g.setColor(colorFg); - g.drawString(hrtValue != undefined ? hrtValue : "-", w2, h3); + g.drawString(hrtValue != undefined ? hrtValue : "-", w, h3); - g.drawImage(heartIcon, w2 - 6, h3 + radiusOuter - 6); + g.drawImage(heartIcon, w - 6, h3 + radiusOuter - 6); } -function drawBattery() { +function drawBattery(w) { + if (!w) w = getCirclePosition("battery"); const battery = E.getBattery(); - const yellow = '#ffff00'; + + // Draw rectangle background: + g.setColor(colorBg); + g.fillRect(w - radiusOuter - 3, h3 - radiusOuter - 3, w + radiusOuter + 3, h3 + radiusOuter + 3); + g.setColor(colorGrey); - g.fillCircle(w3, h3, radiusOuter); + g.fillCircle(w, h3, radiusOuter); if (battery > 0) { const percent = battery / 100; - drawGauge(w3, h3, percent, yellow); + drawGauge(w, h3, percent, colorYellow); } g.setColor(colorBg); - g.fillCircle(w3, h3, radiusInner); + g.fillCircle(w, h3, radiusInner); - g.fillPoly([w3, h3, w3 - 15, h3 + radiusOuter + 5, w3 + 15, h3 + radiusOuter + 5]); + g.fillPoly([w, h3, w - 15, h3 + radiusOuter + 5, w + 15, h3 + radiusOuter + 5]); - g.setFont("Vector:12"); + g.setFont(circleFont); g.setFontAlign(0, 0); let icon = powerIcon; @@ -144,17 +267,95 @@ function drawBattery() { if (Bangle.isCharging()) { color = colorGreen; icon = powerIconGreen; - } - else { + } else { if (settings.batteryWarn != undefined && battery <= settings.batteryWarn) { color = colorRed; icon = powerIconRed; } } g.setColor(color); - g.drawString(battery + '%', w3, h3); + g.drawString(battery + '%', w, h3); - g.drawImage(icon, w3 - 6, h3 + radiusOuter - 6); + g.drawImage(icon, w - 6, h3 + radiusOuter - 6); +} + +function drawWeather(w) { + if (!w) w = getCirclePosition("weather"); + const weather = getWeather(); + const tempString = weather ? locale.temp(weather.temp - 273.15) : undefined; + const code = weather ? weather.code : -1; + + // Draw rectangle background: + g.setColor(colorBg); + g.fillRect(w - radiusOuter - 3, h3 - radiusOuter - 3, w + radiusOuter + 3, h3 + radiusOuter + 3); + + g.setColor(colorGrey); + g.fillCircle(w, h3, radiusOuter); + + g.setColor(colorBg); + g.fillCircle(w, h3, radiusInner); + + g.fillPoly([w, h3, w - 25, h3 + radiusOuter + 5, w + 25, h3 + radiusOuter + 5]); + + const content = tempString ? tempString : "?"; + g.setFont(content.length < 4 ? circleFont : circleFontSmall); + g.setFontAlign(0, 0); + g.setColor(colorFg); + g.drawString(content, w, h3); + + if (code > 0) { + const icon = getWeatherIconByCode(code); + if (icon) g.drawImage(icon, w - 6, h3 + radiusOuter - 10); + } +} + +/* + * Choose weather icon to display based on weather conditition code + * https://openweathermap.org/weather-conditions#Weather-Condition-Codes-2 + */ +function getWeatherIconByCode(code) { + const codeGroup = Math.round(code / 100); + 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; + } + break; + 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; + } + break; + default: + return undefined; + } + return undefined; } function radians(a) { @@ -171,11 +372,11 @@ function drawGauge(cx, cy, percent, color) { if (percent > 1) percent = 1; var startrot = -offset; - var endrot = startrot - ((end - offset) * percent) - 15; + var endrot = startrot - ((end - offset) * percent) - 35; g.setColor(color); - const size = 4; + const size = radiusOuter - radiusInner - 2; // draw gauge for (i = startrot; i > endrot - size; i -= size) { x = cx + r * Math.sin(radians(i)); @@ -198,54 +399,56 @@ function shortValue(v) { } function getSteps() { - if (WIDGETS.wpedom !== undefined) { + if (WIDGETS && WIDGETS.wpedom !== undefined) { return WIDGETS.wpedom.getSteps(); } return 0; } -Bangle.on('lock', function(isLocked) { - if (!isLocked) { - Bangle.setHRMPower(1, "watch"); - if (hrtValue == undefined) { - hrtValue = '...'; - drawHeartRate(); - } - } else { - Bangle.setHRMPower(0, "watch"); - } - drawHeartRate(); - drawSteps(); -}); +function getWeather() { + const jsonWeather = storage.readJSON('weather.json'); + return jsonWeather && jsonWeather.weather ? jsonWeather.weather : undefined; +} -Bangle.on('HRM', function(hrm) { - //if(hrm.confidence > 90){ - hrtValue = hrm.bpm; - if (Bangle.isLCDOn()) +function enableHRMSensor() { + Bangle.setHRMPower(1, "circleclock"); + if (hrtValue == undefined) { + hrtValue = '...'; drawHeartRate(); - //} else { - // hrtValue = undefined; - //} -}); - -Bangle.on('charging', function(charging) { - drawBattery(); -}); - -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. - */ -if (typeof WIDGETS === "object") { - for (let wd of WIDGETS) { - wd.draw = () => {}; - wd.area = ""; } } -loadSettings(); -setInterval(draw, 60000); -draw(); + +Bangle.on('lock', function(isLocked) { + if (!isLocked) { + if (isCircleEnabled("hr")) { + enableHRMSensor(); + } + draw(); + } else { + Bangle.setHRMPower(0, "circleclock"); + } +}); + + +Bangle.on('HRM', function(hrm) { + if (isCircleEnabled("hr")) { + hrtValue = hrm.bpm; + if (Bangle.isLCDOn()) + drawHeartRate(); + } +}); + + Bangle.setUI("clock"); +Bangle.loadWidgets(); + +draw(); +setInterval(draw, 60000); + +Bangle.on('charging', function(charging) { + if (isCircleEnabled("battery")) drawBattery(); +}); + +if (isCircleEnabled("hr")) { + enableHRMSensor(); +} diff --git a/apps/circlesclock/screenshot-dark.png b/apps/circlesclock/screenshot-dark.png new file mode 100644 index 000000000..00c0e3399 Binary files /dev/null and b/apps/circlesclock/screenshot-dark.png differ diff --git a/apps/circlesclock/screenshot-light.png b/apps/circlesclock/screenshot-light.png new file mode 100644 index 000000000..af47b30a4 Binary files /dev/null and b/apps/circlesclock/screenshot-light.png differ diff --git a/apps/circlesclock/screenshot.png b/apps/circlesclock/screenshot.png deleted file mode 100644 index 94ff885fa..000000000 Binary files a/apps/circlesclock/screenshot.png and /dev/null differ diff --git a/apps/circlesclock/settings.js b/apps/circlesclock/settings.js index ffda51538..ac4215a8a 100644 --- a/apps/circlesclock/settings.js +++ b/apps/circlesclock/settings.js @@ -6,13 +6,26 @@ settings[key] = value; storage.write(SETTINGS_FILE, settings); } + var valuesCircleTypes = ["steps", "stepsDist", "hr", "battery", "weather"]; + var namesCircleTypes = ["steps", "distance", "heart", "battery", "weather"]; E.showMenu({ '': { 'title': 'circlesclock' }, + '< Back': back, + 'min heartrate': { + value: "minHR" in settings ? settings.minHR : 40, + min: 0, + max : 250, + step: 5, + format: x => { + return x; + }, + onchange: x => save('minHR', x), + }, 'max heartrate': { value: "maxHR" in settings ? settings.maxHR : 200, min: 20, max : 250, - step: 10, + step: 5, format: x => { return x; }, @@ -28,7 +41,27 @@ }, onchange: x => save('stepGoal', x), }, - 'battery warn lvl': { + 'step length': { + value: "stepLength" in settings ? settings.stepLength : 0.8, + min: 0.1, + max : 1.5, + step: 0.01, + format: x => { + return x; + }, + onchange: x => save('stepLength', x), + }, + 'step dist goal': { + value: "stepDistanceGoal" in settings ? settings.stepDistanceGoal : 8000, + min: 2000, + max : 30000, + step: 1000, + format: x => { + return x; + }, + onchange: x => save('stepDistanceGoal', x), + }, + 'battery warn': { value: "batteryWarn" in settings ? settings.batteryWarn : 30, min: 10, max : 100, @@ -38,6 +71,28 @@ }, onchange: x => save('batteryWarn', x), }, - '< Back': back, + 'show widgets': { + value: "showWidgets" in settings ? settings.showWidgets : false, + format: () => (settings.showWidgets ? 'Yes' : 'No'), + onchange: x => save('showWidgets', x), + }, + 'left': { + value: settings.circle1 ? valuesCircleTypes.indexOf(settings.circle1) : 0, + min: 0, max: 4, + format: v => namesCircleTypes[v], + onchange: x => save('circle1', valuesCircleTypes[x]), + }, + 'middle': { + value: settings.circle2 ? valuesCircleTypes.indexOf(settings.circle2) : 2, + min: 0, max: 4, + format: v => namesCircleTypes[v], + onchange: x => save('circle2', valuesCircleTypes[x]), + }, + 'right': { + value: settings.circle3 ? valuesCircleTypes.indexOf(settings.circle3) : 3, + min: 0, max: 4, + format: v => namesCircleTypes[v], + onchange: x => save('circle3', valuesCircleTypes[x]), + } }); });