Merge pull request #3870 from leonweber/feat/timer-widget

Feat/timer widget
master
Rob Pilling 2025-06-03 22:07:22 +01:00 committed by GitHub
commit 4be6bb2cff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 381 additions and 0 deletions

1
apps/widtimer/ChangeLog Normal file
View File

@ -0,0 +1 @@
0.01: Official release

View File

@ -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"}
]
}

366
apps/widtimer/widget.js Normal file
View File

@ -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();
})();

BIN
apps/widtimer/widtimer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB