diff --git a/apps/BMOface/ChangeLog b/apps/BMOface/ChangeLog new file mode 100644 index 0000000000..c0a67da148 --- /dev/null +++ b/apps/BMOface/ChangeLog @@ -0,0 +1,6 @@ +0.01: Initial release with BMO character +0.02: Added Finn and Jake characters +0.03: Added settings menu for character selection +0.04: Added temperature unit toggle (C/F) +0.05: Fixed settings menu crash, added charging status indicators +0.06: Fixed "Invalid Settings!" error with proper settings file handling \ No newline at end of file diff --git a/apps/BMOface/README.md b/apps/BMOface/README.md new file mode 100644 index 0000000000..3da7d765c3 --- /dev/null +++ b/apps/BMOface/README.md @@ -0,0 +1,21 @@ +BMO Face (Cartoon Face) + +A playful Bangle.js watchface inspired by BMO. Shows time at the top center, temperature (upper-left), heart rate (above steps), and steps (bottom-right). When the watch locks, the face goes to sleep with a light gray background and a -_- expression. + +Features +- Time centered at top using `7x11Numeric7Seg` +- Temperature upper-left +- Steps bottom-right, heart rate just above +- Locked mode: LCD-like gray with `-_-` sleeping face +- Widgets hidden by default; swipe to reveal + +Testing lock state +Use in emulator console: +```javascript +Bangle.setLocked(true); +Bangle.setLocked(false); +``` + +Attribution +Character inspiration: BMO from Adventure Time + diff --git a/apps/BMOface/app-icon.js b/apps/BMOface/app-icon.js new file mode 100644 index 0000000000..40971b3052 --- /dev/null +++ b/apps/BMOface/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("2GwgNVACmqACgr/Ff4rKqkAioGIgArI1EAlQHEgEKAYQrTqlViIrH0WqwQEBlWoGAIIB1GqkUqFY1BqtFEgMVEwIICAgMUFakoK44rCoArILYIrJ0EqFY4FBFaZXLFZKDLFY4ADFaIADV6aDLV5KDLFaWogAgB1WAgQKEFZNUgAgBqtAgIKEFb4ABK4ImDK5oABQYQmBQZwrKqsAbZArCbYIrF1UAbZArCoArHAAQrSAAQr/FZavHFZqvIFb9UgD8BABQpG1EAEwIAKFbgAOEJYr40ECExcAFY1AgIlKqCMEDAWADwgAG0EAhQrFgEAFZVAgEFFYoVBa5Q4BMobWDa5YrH1AVBJQYrcBgKRDFf4r/FdLbH1QVBlQrRJQQqJFZIeBFROq0BkEDwZ1DAA9QMggeDJIYAIHAgmKABQmLABIr/Ff4r/FcUAgAECoAEDFcFUFYMBqtQAgMFFZv+Aom/FZtQv/4FAMAg//worN/4AFFZtAv4VFwIrjh4UE/iIBV5u/FRYrHQIUfAYTcHbbYrDAAgr/Ff4ACbZ4r/qArGgoriqgrqqorGiorjWAquHFbosFBg4reQwSAHFcQALFf4r/Ff4r/FfuKkEqFdEg1BXboEEgArSwEIgArCotUDoMFK6GK1AdBhRXQFYIBBqIrSAIOiV4coFZ9UFahSKFckCQZ1AigrDgIwKV4koEwQvB0ArMihXEAYNQFZkoK4gDBxArLolEFY1EFZeIxArF0CDMoNQFYNRFYVQQZmC0ArB0QrCxCvPgkFFYVEFY1QiivGhEKFYS0DAAeglArDAA1UcAgACoqLCJRCHBFY2KW4QrJWgYrwgi9BUZMCwEqFYsIXoLPJgNAioHEolEoorKAA+IxGKFZQAHoNEGYIrRwWIL4IrRV4JXTV4JXTFYKvLFZKvLAA9QijkBFaOglGoFaQADFaIADFf4r/Fd4A==")) \ No newline at end of file diff --git a/apps/BMOface/app.js b/apps/BMOface/app.js new file mode 100644 index 0000000000..072649a3de --- /dev/null +++ b/apps/BMOface/app.js @@ -0,0 +1,427 @@ +const storage = require('Storage'); + +require("Font6x12").add(Graphics); +require("Font8x12").add(Graphics); +require("Font7x11Numeric7Seg").add(Graphics); + +function bigThenSmall(big, small, x, y) { + g.setFont("7x11Numeric7Seg", 2); + g.drawString(big, x, y); + x += g.stringWidth(big); + g.setFont("8x12"); + g.drawString(small, x, y); +} + +function getBackgroundImage() { + // Cartoon face background - we'll create this + return null; // Placeholder for now +} + +function drawSmileShape(x, y, width, height, thickness) { + // New approach: stamp small circles along an ellipse arc to get + // naturally rounded ends (no polygons changed elsewhere) + var startAngle = Math.PI / 5; + var endAngle = (4 * Math.PI) / 5; + var step = Math.PI / 40; // small, keeps change minimal + var rx = width/1.57; // match previous horizontal scale + var ry = height/2; + var r = Math.max(1, thickness/2); + for (var a=startAngle; a<=endAngle; a+=step) { + var px = x + rx * Math.cos(a); + var py = y + ry * Math.sin(a); + g.fillCircle(px, py, r); + } +} + +function drawLightningBolt(x, y, width, height) { + // Draw lightning bolt using two opposing acute triangles + // x, y = center point + // width = how wide the bolt is + // height = how tall the bolt is + g.setColor(0x000000); + + var halfWidth = width / 2.5; + var halfHeight = height / 1; + + // Upper triangle (pointing down-right) + var upperTriangle = [ + x, y - halfHeight, // Top center point + x + halfWidth, y, // Right middle point + x - halfWidth/2, y + halfHeight/2 // Left lower point + ]; + g.fillPoly(upperTriangle); + + // Lower triangle (pointing up-left) + var lowerTriangle = [ + x, y + halfHeight, // Bottom center point + x - halfWidth, y, // Left middle point + x + halfWidth/2, y - halfHeight/2 // Right upper point + ]; + g.fillPoly(lowerTriangle); +} + +function drawFinnFace() { + var isCharging = Bangle.isCharging(); + + // White squared bottom for hood (behind everything) + g.setColor(0xFFFFFF); // White + g.fillRect(2, 100, 168, 180); // Squared hood bottom (x1, y1, x2, y2) + + // White hood ears + g.setColor(0xFFFFFF); + g.fillCircle(30, 20, 17); // Left ear (x, y, radius) + g.fillCircle(140, 20, 17); // Right ear (x, y, radius) + + // White hood behind face + g.setColor(0xFFFFFF); // White + g.fillCircle(85, 82, 85); // Hood circle (x, y, radius) + + // Finn's face (flesh colored circle) + g.setColor(0.95, 0.8, 0.7); // Flesh color + g.fillCircle(85, 80, 70); // Face circle (x, y, radius) + + // Outlines + g.setColor(0x000000); + g.drawCircle(85, 82, 70); // Face outline + g.drawCircle(85, 85, 85); // Hood outline + + if (isCharging) { + // Lightning bolt eyes when charging + drawLightningBolt(32, 55, 12, 20); // Left lightning bolt + drawLightningBolt(139, 55, 12, 20); // Right lightning bolt + } else { + // Normal circular eyes + g.setColor(0x000000); + g.fillCircle(32, 55, 10); // Left eye (x, y, radius) + g.fillCircle(139, 55, 10); // Right eye (x, y, radius) + } + + // Curved smile using arc + g.setColor(0x000000); + // Draw curved smile: center at (85, 100), radius 20, from 0.2*PI to 0.8*PI + var smilePoints = []; + for (var angle = 0.2 * Math.PI; angle <= 0.8 * Math.PI; angle += 0.1) { + var x = 85 + 20 * Math.cos(angle); + var y = 100 + 20 * Math.sin(angle); + smilePoints.push(x, y); + } + g.drawPoly(smilePoints); +} + +function drawBMOFace() { + var isCharging = Bangle.isCharging(); + + if (isCharging) { + // Lightning bolt eyes when charging + drawLightningBolt(32, 55, 12, 20); // Left lightning bolt (x, y, width, height) + drawLightningBolt(139, 55, 12, 20); // Right lightning bolt (x, y, width, height) + } else { + // Normal circular eyes + g.setColor(0x000000); + g.fillCircle(32, 55, 10); // Left eye - moved up and left + g.fillCircle(139, 55, 10); // Right eye - moved up and left + } + + // BMO mouth structure - all elements follow the same calculated curve + // Black mouth outline + g.setColor(0x424242); + drawSmileShape(85, 86, 40, 20, 29); // Black smile outline + + // Inside of mouth (dark green) + g.setColor(0x225c27); // Dark green + drawSmileShape(85, 85, 43, 20, 20); // Dark green inside smile + + // Tongue (medium green) + g.setColor(0x474747); // Medium green + drawSmileShape(85, 99, 40, 10, 6); // Green tongue smile + + // Curved white tooth line (smile) + g.setColor(0xFFFFFF); + drawSmileShape(85, 80, 50, 12, 4); // White tooth line smile +} + +function drawJakeFace() { + var isCharging = Bangle.isCharging(); + + // Black circles behind Jake's eyes + g.setColor(0x000000); + g.fillCircle(45, 63, 30); // Left black eye background (x, y, radius) + g.fillCircle(115, 63, 30); // Right black eye background (x, y, radius) + + // Jake's white eyes on top of black circles + g.setColor(0xFFFFFF); // White + g.fillCircle(50, 60, 25); // Left eye (x, y, radius) + g.fillCircle(120, 60, 25); // Right eye (x, y, radius) + + // Eye outlines + g.setColor(0x000000); + g.drawCircle(50, 60, 25); // Left eye outline + g.drawCircle(120, 60, 25); // Right eye outline + + if (isCharging) { + // Lightning bolt eyes when charging (inside the white circles) + drawLightningBolt(50, 60, 8, 15); // Left lightning bolt (x, y, width, height) + drawLightningBolt(120, 60, 8, 15); // Right lightning bolt (x, y, width, height) + } + + // Jake's jowls - horizontal pointed oval (like an eye shape) + g.setColor(0xFFFF00); // Yellow + g.fillEllipse(130, 120, 45, 65); // Main jowl oval (center x, center y, width, height) + + // Jowl outline + g.setColor(0x000000); + g.drawEllipse(130, 120, 45, 65); // Main jowl outline (center x, center y, width, height) + g.drawEllipse(45, 130, 70, 77); // Left droop outline + g.drawEllipse(105, 130, 130, 77); // Right droop outline + + g.setColor(0xFFFF00); + g.fillEllipse(47, 125, 68, 75); // Inner left droop oval (center x, center y, width, height) + g.fillEllipse(107, 125, 128, 75); // Inner right droop oval (center x, center y, width, height) + + // Black horizontal oval nose + g.setColor(0x000000); + g.fillEllipse(105, 105, 68, 80); // Nose oval (center x, center y, width, height) +} + +function drawCartoonFace() { + var settings = require("Storage").readJSON("bmoface.settings.json", 1) || {}; + var character = settings.character || "BMO"; + + if (character === "Finn") { + drawFinnFace(); + } else if (character === "Jake") { + drawJakeFace(); + } else { + drawBMOFace(); // Default BMO face + } +} + +// schedule a draw for the next minute +var drawTimeout; +function queueDraw() { + if (drawTimeout) clearTimeout(drawTimeout); + drawTimeout = setTimeout(function() { + drawTimeout = undefined; + draw(); + }, 60000 - (Date.now() % 60000)); +} + +function clearIntervals() { + if (drawTimeout) clearTimeout(drawTimeout); + drawTimeout = undefined; +} + +function drawClock() { + g.setFont("7x11Numeric7Seg", 3); + g.setColor(0, 0, 0); // Black text directly on green background + // Top-center time + var t = require("locale").time(new Date(), 1); + var tx = (g.getWidth() - g.stringWidth(t)) / 2; + g.drawString(t, tx, 8); + g.setFont("8x12", 2); + g.drawString(require("locale").dow(new Date(), 2).toUpperCase(), 18, 130); + g.setFont("8x12"); + g.drawString(require("locale").month(new Date(), 2).toUpperCase(), 80, 126); + g.setFont("8x12", 2); + const time = new Date().getDate(); + g.drawString(time < 10 ? "0" + time : time, 78, 137); +} + +function drawBattery() { + bigThenSmall(E.getBattery(), "%", 146, 8); +} + +function getTemperature(){ + try { + var temperature = E.getTemperature(); + var settings = require("Storage").readJSON("bmoface.settings.json", 1) || {}; + var useFahrenheit = settings.tempUnit === "F"; + + if (useFahrenheit) { + temperature = (temperature * 9/5) + 32; + return Math.round(temperature) + "F"; + } else { + var formatted = require("locale").temp(temperature).replace(/[^\d-]/g, ''); + return formatted; + } + } catch(ex) { + print(ex) + return "?" + } +} + +function getSteps() { + var steps = Bangle.getHealthStatus("day").steps; + steps = Math.round(steps/1000); + return steps + "k"; +} + +function drawBorders() { + // Top border - thin dark teal/green line + g.setColor(0.1, 0.4, 0.3); // Dark teal/green + g.fillRect(0, 0, g.getWidth(), 6); + + // Bottom border - thicker bar (no progress indicator) + g.fillRect(0, g.getHeight() - 8, g.getWidth(), g.getHeight()); +} + +function draw() { + queueDraw(); + + // Clear to character-appropriate background + g.clear(); + var settings = require("Storage").readJSON("bmoface.settings.json", 1) || {}; + var character = settings.character || "BMO"; + + if (character === "Finn") { + g.setColor(0.5, 0.7, 1.0); // Light blue for Finn + } else if (character === "Jake") { + g.setColor(1.0, 1.0, 0.0); // Yellow for Jake + } else { + g.setColor(0.35, 0.78, 0.45); // Light green for BMO + } + g.fillRect(0, 0, g.getWidth(), g.getHeight()); + + // Draw borders (only for BMO, not Finn or Jake) + if (character === "BMO") { + drawBorders(); + } + + // Draw cartoon face + drawCartoonFace(); + + // Draw AdvCasio information like the original + g.setColor(0x000000); // Black text + + g.setFontAlign(-1,-1); + g.setFont("8x12", 2); + // Temperature - upper left + g.drawString(getTemperature(), 6, 6); + + // Steps - bottom right + var stepsStr = getSteps(); + var sx = g.getWidth() - g.stringWidth(stepsStr) - 6; + var sy = g.getHeight() - g.getFontHeight() - 6; + g.drawString(stepsStr, sx, sy); + + // Heart rate just above steps + var hr = Bangle.getHealthStatus().bpm || Bangle.getHealthStatus("last").bpm; + var hrStr = (hr && isFinite(hr)) ? String(Math.round(hr)) : "--"; + var hx = g.getWidth() - g.stringWidth(hrStr) - 6; + var hy = sy - g.getFontHeight() - 2; + g.drawString(hrStr, hx, hy); + + g.setFontAlign(-1,-1); + drawClock(); + drawBattery(); + + // Hide widgets + for (let wd of WIDGETS) {wd.draw=()=>{};wd.area="";} +} + +// Draw the sleeping overlay version when locked +function drawLockedScreen() { + // Light gray background like LCD + g.clear(); + g.setColor(0.8, 0.8, 0.8); + g.fillRect(0, 0, g.getWidth(), g.getHeight()); + + // Draw borders + drawBorders(); + + var isCharging = Bangle.isCharging(); + + var settings = require("Storage").readJSON("bmoface.settings.json", 1) || {}; + var character = settings.character || "BMO"; + + if (isCharging) { + // Lightning bolt eyes when charging (even when locked) + if (character === "Jake") { + // Jake's lightning bolts in white eye circles + g.setColor(0xFFFFFF); // White eye background + g.fillCircle(50, 60, 25); // Left eye + g.fillCircle(120, 60, 25); // Right eye + g.setColor(0x000000); + g.drawCircle(50, 60, 25); // Left eye outline + g.drawCircle(120, 60, 25); // Right eye outline + drawLightningBolt(50, 60, 8, 15); // Left lightning bolt + drawLightningBolt(120, 60, 8, 15); // Right lightning bolt + } else { + // BMO/Finn lightning bolts + drawLightningBolt(32, 55, 12, 20); // Left lightning bolt (x, y, width, height) + drawLightningBolt(139, 55, 12, 20); // Right lightning bolt (x, y, width, height) + } + } else { + // Sleeping face: horizontal slits + g.setColor(0x000000); + if (character === "Jake") { + // Jake's sleeping eyes in white circles + g.setColor(0xFFFFFF); // White eye background + g.fillCircle(50, 60, 25); // Left eye + g.fillCircle(120, 60, 25); // Right eye + g.setColor(0x000000); + g.drawCircle(50, 60, 25); // Left eye outline + g.drawCircle(120, 60, 25); // Right eye outline + // Horizontal slits inside the white circles + g.fillRect(40, 60, 60, 63); // left slit + g.fillRect(110, 60, 130, 63); // right slit + } else { + // BMO/Finn sleeping eyes + g.fillRect(22, 55, 42, 58); // left slit: y fixed by height of 3 px + g.fillRect(129, 55, 149, 58); // right slit + } + } + + // Mouth slit centered (always horizontal when locked) + g.setColor(0x000000); + g.fillRect(60, 90, 120, 83); + + // Redraw information in black at same positions + g.setColor(0x000000); + g.setFontAlign(-1,-1); + g.setFont("8x12", 2); + g.drawString(getTemperature(), 6, 6); + + var stepsStr = getSteps(); + var sx = g.getWidth() - g.stringWidth(stepsStr) - 6; + var sy = g.getHeight() - g.getFontHeight() - 6; + g.drawString(stepsStr, sx, sy); + + var hr = Bangle.getHealthStatus().bpm || Bangle.getHealthStatus("last").bpm; + var hrStr = (hr && isFinite(hr)) ? String(Math.round(hr)) : "--"; + var hx = g.getWidth() - g.stringWidth(hrStr) - 6; + var hy = sy - g.getFontHeight() - 2; + g.drawString(hrStr, hx, hy); + + g.setFontAlign(-1,-1); + drawClock(); + drawBattery(); + + // Keep widgets hidden + for (let wd of WIDGETS) {wd.draw=()=>{};wd.area="";} +} + +Bangle.on("lcdPower", (on) => { + if (on) { + draw(); + } else { + clearIntervals(); + } +}); + +Bangle.on("lock", (locked) => { + clearIntervals(); + if (locked) { + drawLockedScreen(); + } else { + draw(); + } +}); + +Bangle.setUI("clock"); + +// Load widgets, but don't show them +Bangle.loadWidgets(); +require("widget_utils").swipeOn(); // hide widgets, make them visible with a swipe +g.clear(); +draw(); \ No newline at end of file diff --git a/apps/BMOface/metadata.json b/apps/BMOface/metadata.json new file mode 100644 index 0000000000..0427b2821c --- /dev/null +++ b/apps/BMOface/metadata.json @@ -0,0 +1,23 @@ +{ + "id": "bmoface", + "name": "BMO Face", + "shortName": "BMO", + "version": "1.0.6", + "description": "A watch face inspired by BMO that shows time, temp, steps and HR. Sleeps to -_- when locked.", + "icon": "app.png", + "tags": "clock", + "type": "clock", + "supports": ["BANGLEJS", "BANGLEJS2"], + "allow_emulator": true, + "readme": "README.md", + "screenshots": [ + {"url": "screenshot1.png"}, + {"url": "screenshot2.png"}, + {"url": "screenshot3.png"}, + {"url": "screenshot4.png"} + ], + "storage": [ + { "name": "bmoface.app.js", "url": "app.js" }, + { "name": "bmoface.settings.js", "url": "settings.js" } + ] +} \ No newline at end of file diff --git a/apps/BMOface/screenshot1.png b/apps/BMOface/screenshot1.png new file mode 100644 index 0000000000..b12ff07ad2 Binary files /dev/null and b/apps/BMOface/screenshot1.png differ diff --git a/apps/BMOface/screenshot2.png b/apps/BMOface/screenshot2.png new file mode 100644 index 0000000000..5834256113 Binary files /dev/null and b/apps/BMOface/screenshot2.png differ diff --git a/apps/BMOface/screenshot3.png b/apps/BMOface/screenshot3.png new file mode 100644 index 0000000000..0cc3870a38 Binary files /dev/null and b/apps/BMOface/screenshot3.png differ diff --git a/apps/BMOface/screenshot4.png b/apps/BMOface/screenshot4.png new file mode 100644 index 0000000000..f2ba4fd1d3 Binary files /dev/null and b/apps/BMOface/screenshot4.png differ diff --git a/apps/BMOface/settings.js b/apps/BMOface/settings.js new file mode 100644 index 0000000000..3ab35cefd1 --- /dev/null +++ b/apps/BMOface/settings.js @@ -0,0 +1,37 @@ +(function(back) { + var FILE = "bmoface.settings.json"; + + // Load settings with proper defaults + var settings = Object.assign({ + character: "BMO", + tempUnit: "F" + }, require('Storage').readJSON(FILE, true) || {}); + + function writeSettings() { + require('Storage').writeJSON(FILE, settings); + } + + // Show the menu + E.showMenu({ + "" : { "title" : "BMO Face Settings" }, + "< Back" : back, + 'Character': { + value: 0 | ["BMO", "Finn", "Jake"].indexOf(settings.character), + min: 0, max: 2, + format: v => ["BMO", "Finn", "Jake"][v], + onchange: v => { + settings.character = ["BMO", "Finn", "Jake"][v]; + writeSettings(); + } + }, + 'Temperature Unit': { + value: settings.tempUnit === "F" ? 1 : 0, + min: 0, max: 1, + format: v => v ? "Fahrenheit" : "Celsius", + onchange: v => { + settings.tempUnit = v ? "F" : "C"; + writeSettings(); + } + } + }); +}); \ No newline at end of file