Skip to content

Commit 4be6bb2

Browse files
authored
Merge pull request espruino#3870 from leonweber/feat/timer-widget
Feat/timer widget
2 parents ca6bc73 + 1e86413 commit 4be6bb2

File tree

4 files changed

+381
-0
lines changed

4 files changed

+381
-0
lines changed

apps/widtimer/ChangeLog

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0.01: Official release

apps/widtimer/metadata.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"id": "widtimer",
3+
"name": "Timer Widget",
4+
"shortName": "WidTimer",
5+
"version": "0.01",
6+
"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.",
7+
"icon": "widtimer.png",
8+
"type": "widget",
9+
"tags": "widget,timer,gesture",
10+
"supports": ["BANGLEJS2"],
11+
"storage": [
12+
{"name":"widtimer.wid.js","url":"widget.js"}
13+
]
14+
}

apps/widtimer/widget.js

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
/**
2+
* Timer Widget for BangleJS 2
3+
*
4+
* A battery-optimized timer widget with gesture-based controls and accidental activation protection.
5+
* Features double-swipe unlock mechanism, visual feedback, and adaptive refresh rates.
6+
*
7+
* @author Claude AI Assistant
8+
* @version 0.03
9+
*/
10+
(() => {
11+
"use strict";
12+
13+
// =============================================================================
14+
// CONSTANTS
15+
// =============================================================================
16+
17+
/** Timer adjustment constants (in seconds) */
18+
const ONE_MINUTE = 60;
19+
const TEN_MINUTES = 600;
20+
const DEFAULT_TIME = 300; // 5 minutes
21+
22+
/** Refresh rate constants for battery optimization */
23+
const COUNTDOWN_INTERVAL_NORMAL = 10000; // 10 seconds when > 1 minute
24+
const COUNTDOWN_INTERVAL_FINAL = 1000; // 1 second when <= 1 minute
25+
26+
/** Completion notification constants */
27+
const BUZZ_COUNT = 3;
28+
const BUZZ_TOTAL_TIME = 5000; // 5 seconds total
29+
30+
/** Gesture control constants */
31+
const UNLOCK_GESTURE_TIMEOUT = 1500; // milliseconds before unlock gesture has to be started from scratcb
32+
const UNLOCK_CONTROL_TIMEOUT = 5000; // milliseconds before gesture control locks again
33+
const DIRECTION_LEFT = "left";
34+
const DIRECTION_RIGHT = "right";
35+
const DIRECTION_UP = "up";
36+
const DIRECTION_DOWN = "down";
37+
38+
39+
40+
41+
// =============================================================================
42+
// STATE VARIABLES
43+
// =============================================================================
44+
45+
var settings;
46+
var interval = 0;
47+
var remainingTime = 0; // in seconds
48+
49+
// =============================================================================
50+
// UTILITY FUNCTIONS
51+
// =============================================================================
52+
53+
/**
54+
* Format time as MM:SS (allowing MM > 59)
55+
* @param {number} seconds - Time in seconds
56+
* @returns {string} Formatted time string
57+
*/
58+
function formatTime(seconds) {
59+
var mins = Math.floor(seconds / 60);
60+
var secs = seconds % 60;
61+
return mins.toString().padStart(2, '0') + ':' + secs.toString().padStart(2, '0');
62+
}
63+
64+
// =============================================================================
65+
// SETTINGS MANAGEMENT
66+
// =============================================================================
67+
68+
/**
69+
* Save current settings to storage
70+
*/
71+
function saveSettings() {
72+
require('Storage').writeJSON('widtimer.json', settings);
73+
}
74+
75+
/**
76+
* Load settings from storage and calculate current timer state
77+
*/
78+
function loadSettings() {
79+
settings = require('Storage').readJSON('widtimer.json', 1) || {
80+
totalTime: DEFAULT_TIME,
81+
running: false,
82+
startTime: 0
83+
};
84+
85+
// Calculate remaining time if timer was running
86+
if (settings.running && settings.startTime) {
87+
var elapsed = Math.floor((Date.now() - settings.startTime) / 1000);
88+
remainingTime = Math.max(0, settings.totalTime - elapsed);
89+
if (remainingTime === 0) {
90+
settings.running = false;
91+
saveSettings();
92+
}
93+
} else {
94+
remainingTime = settings.totalTime;
95+
}
96+
}
97+
98+
// =============================================================================
99+
// TIMER CONTROL FUNCTIONS
100+
// =============================================================================
101+
102+
/**
103+
* Main countdown function - handles timer progression and battery optimization
104+
*/
105+
function countdown() {
106+
if (!settings.running) return;
107+
108+
var elapsed = Math.floor((Date.now() - settings.startTime) / 1000);
109+
var oldRemainingTime = remainingTime;
110+
remainingTime = Math.max(0, settings.totalTime - elapsed);
111+
112+
// Switch to faster refresh when entering final minute for better accuracy
113+
if (oldRemainingTime > 60 && remainingTime <= 60 && interval) {
114+
clearInterval(interval);
115+
interval = setInterval(countdown, COUNTDOWN_INTERVAL_FINAL);
116+
}
117+
118+
if (remainingTime <= 0) {
119+
// Timer finished - provide completion notification
120+
buzzMultiple();
121+
settings.running = false;
122+
remainingTime = settings.totalTime; // Reset to original time
123+
saveSettings();
124+
if (interval) {
125+
clearInterval(interval);
126+
interval = 0;
127+
}
128+
}
129+
130+
WIDGETS["widtimer"].draw();
131+
}
132+
133+
/**
134+
* Generate multiple buzzes for timer completion notification
135+
*/
136+
function buzzMultiple() {
137+
var buzzInterval = BUZZ_TOTAL_TIME / BUZZ_COUNT;
138+
for (var i = 0; i < BUZZ_COUNT; i++) {
139+
(function(delay) {
140+
setTimeout(function() {
141+
Bangle.buzz(300);
142+
}, delay);
143+
})(i * buzzInterval);
144+
}
145+
}
146+
147+
/**
148+
* Start the timer with battery-optimized refresh rate
149+
*/
150+
function startTimer() {
151+
if (remainingTime > 0 && !settings.running) {
152+
settings.running = true;
153+
settings.startTime = Date.now();
154+
saveSettings();
155+
if (!interval) {
156+
// Use different intervals based on remaining time for battery optimization
157+
var intervalTime = remainingTime <= 60 ? COUNTDOWN_INTERVAL_FINAL : COUNTDOWN_INTERVAL_NORMAL;
158+
interval = setInterval(countdown, intervalTime);
159+
}
160+
}
161+
}
162+
163+
/**
164+
* Adjust timer by specified number of seconds
165+
* @param {number} seconds - Positive or negative adjustment in seconds
166+
*/
167+
function adjustTimer(seconds) {
168+
if (settings.running) {
169+
// For running timer, adjust both total time and remaining time
170+
settings.totalTime = Math.max(0, settings.totalTime + seconds);
171+
remainingTime = Math.max(0, remainingTime + seconds);
172+
173+
// If remaining time becomes 0 or negative, stop the timer
174+
if (remainingTime <= 0) {
175+
settings.running = false;
176+
remainingTime = 0;
177+
if (interval) {
178+
clearInterval(interval);
179+
interval = 0;
180+
}
181+
// Provide feedback if timer finished due to negative adjustment
182+
if (remainingTime === 0) {
183+
buzzMultiple();
184+
}
185+
}
186+
} else {
187+
// Adjust stopped timer
188+
settings.totalTime = Math.max(0, settings.totalTime + seconds);
189+
remainingTime = settings.totalTime;
190+
191+
}
192+
193+
saveSettings();
194+
WIDGETS["widtimer"].draw();
195+
}
196+
197+
// =============================================================================
198+
// GESTURE CONTROL SYSTEM
199+
// =============================================================================
200+
201+
// Gesture state variables
202+
var drag = null;
203+
var lastSwipeTime = 0;
204+
var lastSwipeDirection = null;
205+
var isControlLocked = true;
206+
207+
/**
208+
* Reset gesture controls to locked state
209+
*/
210+
function resetUnlock() {
211+
isControlLocked = true;
212+
WIDGETS["widtimer"].draw();
213+
}
214+
215+
function isHorizontal(direction) {
216+
return (direction == DIRECTION_LEFT) || (direction == DIRECTION_RIGHT)
217+
}
218+
219+
function isVertical(direction) {
220+
return (direction == DIRECTION_UP) || (direction == DIRECTION_DOWN)
221+
}
222+
223+
function isUnlockGesture(first_direction, second_direction) {
224+
return (isHorizontal(first_direction) && isVertical(second_direction)
225+
|| isVertical(first_direction) && isHorizontal(second_direction))
226+
}
227+
228+
/**
229+
* Set up gesture handlers with double-swipe protection against accidental activation
230+
*/
231+
function setupGestures() {
232+
Bangle.on("drag", function(e) {
233+
if (!drag) {
234+
// Start tracking drag gesture
235+
drag = {x: e.x, y: e.y};
236+
} else if (!e.b) {
237+
// Drag gesture completed
238+
var dx = e.x - drag.x;
239+
var dy = e.y - drag.y;
240+
drag = null;
241+
242+
// Only process significant gestures
243+
if (Math.abs(dx) > 20 || Math.abs(dy) > 20) {
244+
var currentTime = Date.now();
245+
var direction = null;
246+
var adjustment = 0;
247+
248+
// Determine gesture direction and timer adjustment
249+
if (Math.abs(dx) > Math.abs(dy) + 10) {
250+
// Horizontal swipe detected
251+
if (dx > 0) {
252+
direction = 'right';
253+
adjustment = ONE_MINUTE;
254+
} else {
255+
direction = 'left';
256+
adjustment = -ONE_MINUTE;
257+
}
258+
} else if (Math.abs(dy) > Math.abs(dx) + 10) {
259+
// Vertical swipe detected
260+
if (dy > 0) {
261+
direction = 'down';
262+
adjustment = -TEN_MINUTES;
263+
} else {
264+
direction = 'up';
265+
adjustment = TEN_MINUTES;
266+
}
267+
}
268+
269+
if (direction) {
270+
// Process gesture based on lock state
271+
if (!isControlLocked) {
272+
// Controls unlocked - execute adjustment immediately
273+
adjustTimer(adjustment);
274+
} else if (isUnlockGesture(direction, lastSwipeDirection) &&
275+
currentTime - lastSwipeTime < UNLOCK_GESTURE_TIMEOUT) {
276+
// Double swipe detected - unlock controls and execute
277+
isControlLocked = false;
278+
// adjustTimer(adjustment);
279+
Bangle.buzz(50); // Provide unlock feedback
280+
281+
// Auto-start if time > 0
282+
if (settings.totalTime > 0) {
283+
startTimer();
284+
}
285+
286+
// Auto-lock after `UNLOCK_CONTROL_TIMEOUT` seconds of inactivity
287+
setTimeout(resetUnlock, UNLOCK_CONTROL_TIMEOUT);
288+
}
289+
290+
// Update gesture tracking state
291+
lastSwipeDirection = direction;
292+
lastSwipeTime = currentTime;
293+
}
294+
}
295+
}
296+
});
297+
}
298+
299+
// =============================================================================
300+
// WIDGET DEFINITION
301+
// =============================================================================
302+
303+
/**
304+
* Main widget object following BangleJS widget conventions
305+
*/
306+
WIDGETS["widtimer"] = {
307+
area: "tl",
308+
width: 58, // Optimized width for vector font display
309+
310+
/**
311+
* Draw the widget with current timer state and visual feedback
312+
*/
313+
draw: function() {
314+
g.reset();
315+
g.setFontAlign(0, 0);
316+
g.clearRect(this.x, this.y, this.x + this.width, this.y + 23);
317+
318+
// Use vector font for crisp, scalable display
319+
g.setFont("Vector", 16);
320+
var timeStr = formatTime(remainingTime);
321+
322+
// Set color based on current timer state
323+
if (settings.running && remainingTime > 0) {
324+
g.setColor("#ffff00"); // Yellow when running (visible on colored backgrounds)
325+
} else if (remainingTime === 0) {
326+
g.setColor("#ff0000"); // Red when finished
327+
} else if (!isControlLocked) {
328+
g.setColor("#00ff88"); // Light green when controls unlocked
329+
} else {
330+
g.setColor("#ffffff"); // White when stopped/locked
331+
}
332+
333+
g.drawString(timeStr, this.x + this.width/2, this.y + 12);
334+
g.setColor("#ffffff"); // Reset graphics color
335+
},
336+
337+
/**
338+
* Reload widget state from storage and restart timer if needed
339+
*/
340+
reload: function() {
341+
loadSettings();
342+
343+
// Clear any existing countdown interval
344+
if (interval) {
345+
clearInterval(interval);
346+
interval = 0;
347+
}
348+
349+
// Restart countdown if timer was previously running
350+
if (settings.running && remainingTime > 0) {
351+
var intervalTime = remainingTime <= 60 ? COUNTDOWN_INTERVAL_FINAL : COUNTDOWN_INTERVAL_NORMAL;
352+
interval = setInterval(countdown, intervalTime);
353+
}
354+
355+
this.draw();
356+
}
357+
};
358+
359+
// =============================================================================
360+
// INITIALIZATION
361+
// =============================================================================
362+
363+
// Initialize widget and set up gesture handlers
364+
WIDGETS["widtimer"].reload();
365+
setupGestures();
366+
})();

apps/widtimer/widtimer.png

2.03 KB
Loading

0 commit comments

Comments
 (0)