diff --git a/apps/widtimer/ChangeLog b/apps/widtimer/ChangeLog new file mode 100644 index 000000000..f451ada48 --- /dev/null +++ b/apps/widtimer/ChangeLog @@ -0,0 +1 @@ +0.01: Official release \ No newline at end of file diff --git a/apps/widtimer/metadata.json b/apps/widtimer/metadata.json new file mode 100644 index 000000000..18ed76666 --- /dev/null +++ b/apps/widtimer/metadata.json @@ -0,0 +1,14 @@ +{ + "id": "widtimer", + "name": "Timer Widget", + "shortName": "WidTimer", + "version": "0.01", + "description": "Timer widget with swipe controls. Swipe a T (for timer) to start the timer and unlock the controls. Then single swipes adjust the time: right/left ±1min, up/down ±10min. Will buzz upon timer completion.", + "icon": "widtimer.png", + "type": "widget", + "tags": "widget,timer,gesture", + "supports": ["BANGLEJS2"], + "storage": [ + {"name":"widtimer.wid.js","url":"widget.js"} + ] +} \ No newline at end of file diff --git a/apps/widtimer/widget.js b/apps/widtimer/widget.js new file mode 100644 index 000000000..5a0e2809d --- /dev/null +++ b/apps/widtimer/widget.js @@ -0,0 +1,366 @@ +/** + * Timer Widget for BangleJS 2 + * + * A battery-optimized timer widget with gesture-based controls and accidental activation protection. + * Features double-swipe unlock mechanism, visual feedback, and adaptive refresh rates. + * + * @author Claude AI Assistant + * @version 0.03 + */ +(() => { + "use strict"; + + // ============================================================================= + // CONSTANTS + // ============================================================================= + + /** Timer adjustment constants (in seconds) */ + const ONE_MINUTE = 60; + const TEN_MINUTES = 600; + const DEFAULT_TIME = 300; // 5 minutes + + /** Refresh rate constants for battery optimization */ + const COUNTDOWN_INTERVAL_NORMAL = 10000; // 10 seconds when > 1 minute + const COUNTDOWN_INTERVAL_FINAL = 1000; // 1 second when <= 1 minute + + /** Completion notification constants */ + const BUZZ_COUNT = 3; + const BUZZ_TOTAL_TIME = 5000; // 5 seconds total + + /** Gesture control constants */ + const UNLOCK_GESTURE_TIMEOUT = 1500; // milliseconds before unlock gesture has to be started from scratcb + const UNLOCK_CONTROL_TIMEOUT = 5000; // milliseconds before gesture control locks again + const DIRECTION_LEFT = "left"; + const DIRECTION_RIGHT = "right"; + const DIRECTION_UP = "up"; + const DIRECTION_DOWN = "down"; + + + + + // ============================================================================= + // STATE VARIABLES + // ============================================================================= + + var settings; + var interval = 0; + var remainingTime = 0; // in seconds + + // ============================================================================= + // UTILITY FUNCTIONS + // ============================================================================= + + /** + * Format time as MM:SS (allowing MM > 59) + * @param {number} seconds - Time in seconds + * @returns {string} Formatted time string + */ + function formatTime(seconds) { + var mins = Math.floor(seconds / 60); + var secs = seconds % 60; + return mins.toString().padStart(2, '0') + ':' + secs.toString().padStart(2, '0'); + } + + // ============================================================================= + // SETTINGS MANAGEMENT + // ============================================================================= + + /** + * Save current settings to storage + */ + function saveSettings() { + require('Storage').writeJSON('widtimer.json', settings); + } + + /** + * Load settings from storage and calculate current timer state + */ + function loadSettings() { + settings = require('Storage').readJSON('widtimer.json', 1) || { + totalTime: DEFAULT_TIME, + running: false, + startTime: 0 + }; + + // Calculate remaining time if timer was running + if (settings.running && settings.startTime) { + var elapsed = Math.floor((Date.now() - settings.startTime) / 1000); + remainingTime = Math.max(0, settings.totalTime - elapsed); + if (remainingTime === 0) { + settings.running = false; + saveSettings(); + } + } else { + remainingTime = settings.totalTime; + } + } + + // ============================================================================= + // TIMER CONTROL FUNCTIONS + // ============================================================================= + + /** + * Main countdown function - handles timer progression and battery optimization + */ + function countdown() { + if (!settings.running) return; + + var elapsed = Math.floor((Date.now() - settings.startTime) / 1000); + var oldRemainingTime = remainingTime; + remainingTime = Math.max(0, settings.totalTime - elapsed); + + // Switch to faster refresh when entering final minute for better accuracy + if (oldRemainingTime > 60 && remainingTime <= 60 && interval) { + clearInterval(interval); + interval = setInterval(countdown, COUNTDOWN_INTERVAL_FINAL); + } + + if (remainingTime <= 0) { + // Timer finished - provide completion notification + buzzMultiple(); + settings.running = false; + remainingTime = settings.totalTime; // Reset to original time + saveSettings(); + if (interval) { + clearInterval(interval); + interval = 0; + } + } + + WIDGETS["widtimer"].draw(); + } + + /** + * Generate multiple buzzes for timer completion notification + */ + function buzzMultiple() { + var buzzInterval = BUZZ_TOTAL_TIME / BUZZ_COUNT; + for (var i = 0; i < BUZZ_COUNT; i++) { + (function(delay) { + setTimeout(function() { + Bangle.buzz(300); + }, delay); + })(i * buzzInterval); + } + } + + /** + * Start the timer with battery-optimized refresh rate + */ + function startTimer() { + if (remainingTime > 0 && !settings.running) { + settings.running = true; + settings.startTime = Date.now(); + saveSettings(); + if (!interval) { + // Use different intervals based on remaining time for battery optimization + var intervalTime = remainingTime <= 60 ? COUNTDOWN_INTERVAL_FINAL : COUNTDOWN_INTERVAL_NORMAL; + interval = setInterval(countdown, intervalTime); + } + } + } + + /** + * Adjust timer by specified number of seconds + * @param {number} seconds - Positive or negative adjustment in seconds + */ + function adjustTimer(seconds) { + if (settings.running) { + // For running timer, adjust both total time and remaining time + settings.totalTime = Math.max(0, settings.totalTime + seconds); + remainingTime = Math.max(0, remainingTime + seconds); + + // If remaining time becomes 0 or negative, stop the timer + if (remainingTime <= 0) { + settings.running = false; + remainingTime = 0; + if (interval) { + clearInterval(interval); + interval = 0; + } + // Provide feedback if timer finished due to negative adjustment + if (remainingTime === 0) { + buzzMultiple(); + } + } + } else { + // Adjust stopped timer + settings.totalTime = Math.max(0, settings.totalTime + seconds); + remainingTime = settings.totalTime; + + } + + saveSettings(); + WIDGETS["widtimer"].draw(); + } + + // ============================================================================= + // GESTURE CONTROL SYSTEM + // ============================================================================= + + // Gesture state variables + var drag = null; + var lastSwipeTime = 0; + var lastSwipeDirection = null; + var isControlLocked = true; + + /** + * Reset gesture controls to locked state + */ + function resetUnlock() { + isControlLocked = true; + WIDGETS["widtimer"].draw(); + } + + function isHorizontal(direction) { + return (direction == DIRECTION_LEFT) || (direction == DIRECTION_RIGHT) + } + + function isVertical(direction) { + return (direction == DIRECTION_UP) || (direction == DIRECTION_DOWN) + } + + function isUnlockGesture(first_direction, second_direction) { + return (isHorizontal(first_direction) && isVertical(second_direction) + || isVertical(first_direction) && isHorizontal(second_direction)) + } + + /** + * Set up gesture handlers with double-swipe protection against accidental activation + */ + function setupGestures() { + Bangle.on("drag", function(e) { + if (!drag) { + // Start tracking drag gesture + drag = {x: e.x, y: e.y}; + } else if (!e.b) { + // Drag gesture completed + var dx = e.x - drag.x; + var dy = e.y - drag.y; + drag = null; + + // Only process significant gestures + if (Math.abs(dx) > 20 || Math.abs(dy) > 20) { + var currentTime = Date.now(); + var direction = null; + var adjustment = 0; + + // Determine gesture direction and timer adjustment + if (Math.abs(dx) > Math.abs(dy) + 10) { + // Horizontal swipe detected + if (dx > 0) { + direction = 'right'; + adjustment = ONE_MINUTE; + } else { + direction = 'left'; + adjustment = -ONE_MINUTE; + } + } else if (Math.abs(dy) > Math.abs(dx) + 10) { + // Vertical swipe detected + if (dy > 0) { + direction = 'down'; + adjustment = -TEN_MINUTES; + } else { + direction = 'up'; + adjustment = TEN_MINUTES; + } + } + + if (direction) { + // Process gesture based on lock state + if (!isControlLocked) { + // Controls unlocked - execute adjustment immediately + adjustTimer(adjustment); + } else if (isUnlockGesture(direction, lastSwipeDirection) && + currentTime - lastSwipeTime < UNLOCK_GESTURE_TIMEOUT) { + // Double swipe detected - unlock controls and execute + isControlLocked = false; + // adjustTimer(adjustment); + Bangle.buzz(50); // Provide unlock feedback + + // Auto-start if time > 0 + if (settings.totalTime > 0) { + startTimer(); + } + + // Auto-lock after `UNLOCK_CONTROL_TIMEOUT` seconds of inactivity + setTimeout(resetUnlock, UNLOCK_CONTROL_TIMEOUT); + } + + // Update gesture tracking state + lastSwipeDirection = direction; + lastSwipeTime = currentTime; + } + } + } + }); + } + + // ============================================================================= + // WIDGET DEFINITION + // ============================================================================= + + /** + * Main widget object following BangleJS widget conventions + */ + WIDGETS["widtimer"] = { + area: "tl", + width: 58, // Optimized width for vector font display + + /** + * Draw the widget with current timer state and visual feedback + */ + draw: function() { + g.reset(); + g.setFontAlign(0, 0); + g.clearRect(this.x, this.y, this.x + this.width, this.y + 23); + + // Use vector font for crisp, scalable display + g.setFont("Vector", 16); + var timeStr = formatTime(remainingTime); + + // Set color based on current timer state + if (settings.running && remainingTime > 0) { + g.setColor("#ffff00"); // Yellow when running (visible on colored backgrounds) + } else if (remainingTime === 0) { + g.setColor("#ff0000"); // Red when finished + } else if (!isControlLocked) { + g.setColor("#00ff88"); // Light green when controls unlocked + } else { + g.setColor("#ffffff"); // White when stopped/locked + } + + g.drawString(timeStr, this.x + this.width/2, this.y + 12); + g.setColor("#ffffff"); // Reset graphics color + }, + + /** + * Reload widget state from storage and restart timer if needed + */ + reload: function() { + loadSettings(); + + // Clear any existing countdown interval + if (interval) { + clearInterval(interval); + interval = 0; + } + + // Restart countdown if timer was previously running + if (settings.running && remainingTime > 0) { + var intervalTime = remainingTime <= 60 ? COUNTDOWN_INTERVAL_FINAL : COUNTDOWN_INTERVAL_NORMAL; + interval = setInterval(countdown, intervalTime); + } + + this.draw(); + } + }; + + // ============================================================================= + // INITIALIZATION + // ============================================================================= + + // Initialize widget and set up gesture handlers + WIDGETS["widtimer"].reload(); + setupGestures(); +})(); \ No newline at end of file diff --git a/apps/widtimer/widtimer.png b/apps/widtimer/widtimer.png new file mode 100644 index 000000000..d167a2bfb Binary files /dev/null and b/apps/widtimer/widtimer.png differ