Merge pull request #3868 from ticalc-travis/tevtimer

New app: tev's timer
master
Rob Pilling 2025-06-04 08:04:14 +01:00 committed by GitHub
commit 6cc628f173
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 2459 additions and 0 deletions

1
apps/tevtimer/ChangeLog Normal file
View File

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

92
apps/tevtimer/README.md Normal file
View File

@ -0,0 +1,92 @@
# tev's timer
This Bangle.js 2 app aims to be an ergonomic timer that features:
* Large, easy-to-read fonts
* Multiple simultaneous timer operation
* Interval and repeat timers
* A customizable three-line display
![App screenshot](screenshot.png)
## Basic usage and controls
The main timer screen appears when you start the app. The button on the lower-left of the screen starts or stops the timer. The lower-right button provides a menu for all other functions. If you have created more than one timer, you can swipe left or right above the buttons to quickly switch between them.
## Timer menu
The on-screen menu button displays the following menu items:
* **Reset:** Reset the currently displayed timer back to its starting value.
* **Timers:** Select a different timer to display. The most recently used timer is automatically moved to the top of the list.
* **Edit:** Edit the currently displayed timer
* **Format:** Customize the timer display
* **Add:** Create a new timer with the same parameters as the currently displayed one. The Edit menu will appear allowing you to adjust the newly created timer.
* **Delete:** Delete the currently displayed timer
* **Settings:** Adjust global app settings
## Editing a timer
The following parameters can be adjusted individually for each timer by displaying that timer on the main screen and then selecting **Edit** from the menu:
* **Name:** (available when a keyboard app is installed) Allows you to assign a custom name to the timer to display in menus
* **Start:** Set the starting time of the timer
* **At end:** Allows for creating interval or repeat timers. Selecting “Stop” causes the timer to simply stop when it expires and the resulting alert is dismissed. Selecting a timer here will cause the selected timer to reset and automatically begin counting down once this timer expires. See “Chained timers” below.
* **Vibrate pattern:** Choose the vibration pattern for the alert when the timer expires
* **Buzz count:** Choose the number of times the vibration pattern signals when the timer expires before silencing itself
## Chained timers
When a timer reaches its end, it can be configured to automatically start another timer, forming a chain of timers. For instance, if you create a timer A and a timer B, you can set timer A's **at end** setting to point to timer B. Then when timer A expires, timer B will automatically start. You can then edit timer B's **at end** setting to auto-start yet another timer, and so on. This procedure can be used to create an interval timer. You can also chain a timer to itself to create a repeating timer. If you set timer A's **at end** setting to timer A itself, timer A will repeat indefinitely, sounding an alert each time it expires, until you manually pause the timer. You can furthermore chain a series of timers back to itself to create a repeating set of intervals: timer A to timer B to timer C back to timer A, for instance.
## Display format
Selecting Format from the menu allows you to customize the display. The display has three lines, and each one can be set to one of the following general modes:
* **Current:** Shows the current timer position as it counts down
* **Start:** Shows the starting point of the timer
* **Time:** Shows the current time of day
* **Name:** Shows the name of the timer (if set by a keyboard app; otherwise displays an auto-generated name based on the timer's starting point and current position)
The Current, Start, and Time modes each have three subtypes allowing you to set the precision of the displayed time:
* **HMS:** Hours, minutes, and seconds
* **HM:** Hours and minutes only
* **Auto:** Displays only hours and minutes while the screen is locked; when unlocked, automatically displays the seconds too
The primary benefit to choosing a mode that hides the seconds is to reduce battery consumption when the timer is being displayed for an extended period of time.
## App settings
The Settings option in the menu contains the following options which apply to all timers or to the app as a whole.
### Button, Tap left, and Tap right
Choose a shortcut action to perform when the physical button is pressed (after the screen is unlocked), when the left side of the touch screen (above the buttons when the main time screen is displayed) is tapped, and when the right side of the touch screen is tapped, respectively. By default, pressing the button toggles the timer between running and paused, and tapping either side of the screen brings up the screen for setting the starting time of the timer. These actions can be customized:
* **Start/stop:** Toggle the timer between paused and running
* **Reset:** Reset the timer
* **Timers:** Display the timer selection menu to display a different timer
* **Edit timer:** Display the timer edit menu
* **Edit start:** Display the timer start time edit screen
* **Format:** Display the display format selection screen
### Confirm reset
Normally when you choose to reset a timer, a menu prompts you to confirm the reset if the timer has not expired yet (the Auto option). This helps protect against accidentally resetting the timer. If you prefer to always see this confirmation menu, choose Always; if you would rather reset always happen instantly, choose Never.
### Confirm delete
Likewise, to protect against accidentally deleting a timer a confirmation menu appears when you select Delete from the menu. Setting this option to Never eliminates this extra step.
### On alarm go to
If set to Clock (default), when a timer expires and its alert is displayed, dismissing the alert will return to the default app (normally the preferred clock app). Setting this option to Timer will automatically restart the Tev Timer app instead.
### Auto reset
When a timer expires, it will by default begin counting up a time that represents the amount of time passed before its alarm was acknowledged. If you check this option, the timer will be reset back to its starting point instead, saving you the trouble of doing so manually before using the timer again.
## Timer alerts
When a timer expires, it will display an alert like the ones produced by the standard Scheduler app. For a timer that is not chained, or the last timer in a chain, two buttons “OK” and “Snooze” appear when an alert fires. “OK” completely dismisses the alert, while “Snooze” temporarily dismisses it (it will recur after the snooze interval configured in the Scheduler settings). For chained timers, the options are instead “OK” and “Halt”. “OK” dismisses the individual alert while allowing the next chained timer to continue running in the background, eventually sounding its alert. “Halt” stops the timer and cancels the chaining action, and the display will return to the timer that was running at the point when the “Halt” button was tapped. If you accidentally create a repeating timer that alerts too frequently and makes it impossible to use the Bangle.js watch normally, quickly tap the “Halt” button to stop the chaining action and regain control.

156
apps/tevtimer/alarm.js Normal file
View File

@ -0,0 +1,156 @@
// Derived from `sched.js` from the `sched` app, with modifications
// for features unique to the `tevtimer` app.
// Chances are boot0.js got run already and scheduled *another*
// 'load(sched.js)' - so let's remove it first!
if (Bangle.SCHED) {
clearInterval(Bangle.SCHED);
delete Bangle.SCHED;
}
const tt = require('tevtimer');
function showAlarm(alarm) {
// Alert the user of the alarm and handle the response
const settings = require("sched").getSettings();
const timer = tt.TIMERS[tt.find_timer_by_id(alarm.id)];
if (timer === undefined) {
console.error("tevtimer: unable to find timer with ID " + alarm.id);
return;
}
let message = timer.display_name() + '\n' + alarm.msg;
// Altering alarms from here is tricky. Making changes to timers
// requires calling tt.update_system_alarms() to update the system
// alarm list to reflect the new timer state. But that means we need
// to retrieve the alarms again from sched.getAlarms() before
// changing them ourselves or else we risk overwriting the changes.
// Likewise, after directly modifying alarms, we need to write them
// back with sched.setAlarms() before doing anything that will call
// tt.update_system_alarms(), or else the latter will work with an
// outdated list of alarms.
// If there's a timer chained from this one, start it (only for
// alarms not in snoozed status)
var isChainedTimer = false;
var chainTimer = null;
if (timer.chain_id !== null && alarm.ot === undefined) {
chainTimer = tt.TIMERS[tt.find_timer_by_id(timer.chain_id)];
if (chainTimer !== undefined) {
chainTimer.reset();
chainTimer.start();
tt.set_last_viewed_timer(chainTimer);
isChainedTimer = true;
// Update system alarm list
tt.update_system_alarms();
alarms = require("sched").getAlarms();
} else {
console.warn("tevtimer: unable to find chained timer with ID " + timer.chain_id);
}
}
if (alarm.msg) {
message += "\n" + alarm.msg;
} else {
message = atob("ACQswgD//33vRcGHIQAAABVVVAAAAAAAABVVVAAAAAAAABVVVAAAAAAAABVVVAAAAAAAABVVVAAAAAAAABVVVAAAAAAAAAP/wAAAAAAAAAP/wAAAAAAAAAqqoAPAAAAAAqqqqoP8AAAAKqqqqqv/AAACqqqqqqq/wAAKqqqlWqqvwAAqqqqlVaqrAACqqqqlVVqqAAKqqqqlVVaqgAKqaqqlVVWqgAqpWqqlVVVqoAqlWqqlVVVaoCqlV6qlVVVaqCqVVfqlVVVWqCqVVf6lVVVWqKpVVX/lVVVVqqpVVV/+VVVVqqpVVV//lVVVqqpVVVfr1VVVqqpVVVfr1VVVqqpVVVb/lVVVqqpVVVW+VVVVqqpVVVVVVVVVqiqVVVVVVVVWqCqVVVVVVVVWqCqlVVVVVVVaqAqlVVVVVVVaoAqpVVVVVVVqoAKqVVVVVVWqgAKqlVVVVVaqgACqpVVVVVqqAAAqqlVVVaqoAAAKqqVVWqqgAAACqqqqqqqAAAAAKqqqqqgAAAAAAqqqqoAAAAAAAAqqoAAAAA==") + " " + message
}
Bangle.loadWidgets();
Bangle.drawWidgets();
// buzzCount should really be called buzzRepeat, so subtract 1
let buzzCount = timer.buzz_count - 1;
// Alarm options for non-chained timer are OK (dismiss the alarm) and
// Snooze (retrigger the alarm after a delay).
// Alarm options for chained timer are OK (dismiss) and Halt (dismiss
// and pause the triggering timer).
let promptButtons = isChainedTimer
? { 'Halt': 'halt', 'OK': 'ok' }
: { 'Snooze': 'snooze', 'OK': 'ok' };
E.showPrompt(message, {
title: 'tev timer',
buttons: promptButtons,
}).then(function (action) {
buzzCount = 0;
if (action === 'snooze') {
if (alarm.ot === undefined) {
alarm.ot = alarm.t;
}
let time = new Date();
let currentTime = (time.getHours()*3600000)+(time.getMinutes()*60000)+(time.getSeconds()*1000);
alarm.t = currentTime + settings.defaultSnoozeMillis;
alarm.t %= 86400000;
require("sched").setAlarms(alarms);
Bangle.emit("alarmSnooze", alarm);
}
if (action === 'ok' || action === 'halt') {
let index = alarms.indexOf(alarm);
if (index !== -1) {
alarms.splice(index, 1);
require("sched").setAlarms(alarms);
}
if (timer !== chainTimer) {
timer.pause();
if (tt.SETTINGS.auto_reset) {
timer.reset();
}
}
}
if (action === 'halt') {
chainTimer.pause();
}
tt.update_system_alarms();
alarms = require("sched").getAlarms();
Bangle.emit("alarmDismiss", alarm);
require("sched").setAlarms(alarms);
if (action === 'halt' || tt.SETTINGS.alarm_return) {
load('tevtimer.app.js');
} else {
load();
}
});
function buzz() {
// Handle buzzing and screen unlocking
if (settings.unlockAtBuzz) {
Bangle.setLocked(false);
}
const pattern = timer.vibrate_pattern || settings.defaultTimerPattern;
console.log('buzz: ' + pattern);
console.log('buzzCount: ' + buzzCount);
require("buzz").pattern(pattern).then(() => {
if (buzzCount == null || buzzCount--) {
setTimeout(buzz, settings.buzzIntervalMillis);
} else if (alarm.as) { // auto-snooze
// buzzCount should really be called buzzRepeat, so subtract 1
buzzCount = timer.buzz_count - 1;
setTimeout(buzz, settings.defaultSnoozeMillis);
}
});
}
if ((require("Storage").readJSON("setting.json", 1) || {}).quiet > 1)
return;
buzz();
}
let alarms = require("sched").getAlarms();
let active = require("sched").getActiveAlarms(alarms);
if (active.length) {
// if there's an alarm, show it
showAlarm(active[0]);
} else {
// otherwise just go back to default app
setTimeout(load, 100);
}

View File

@ -0,0 +1 @@
require("heatshrink").decompress(atob("mEwwcCpMgBo8EyVJkgCECKMki9duvXAQVcBwwCCwXr126AQXhCJOQEYsJCJQjFCJWSjojD6AOIAQVDEYeCCJdBuvAQI8SCItIl2ACJ1JgOAjlxNwdduA4HhkwgeONwfrwgRHkOGgC2PmHDSQnoCJMGjAjEoIRJwEBhy2OCIMF66AHgAREnHIkG6CJsWjkkhMg+fHSQd14QRDkG45eEIYMevSSD1y2EgvWjtwwMkwa2KhwjB0GQki2F0DpEuojBukQkmREYkECIdDa4PDhkCgEEnS2IoFhw0YsOCCIMXbREDEYcw8+gwQjC8IRDyAgBEYWAjnwyAMCZAmQEAQCBgHn10QCI4gCAQNgg8c+vCTgMgYQhEDAQM4EYOCoVJgTCEbwkB44jB6ARMBQkIgDUEAQoRQpAKEkfOwQRIoAHEkF54QROg/O3ARIeIlInn566hEWwZMF8/O3QRHwCBEWwN569BCJgtJAQ4RQkJjJAQxUFQ4oRWpDPLAQlCeZYCEQAgADCKI"))

1324
apps/tevtimer/app.js Normal file

File diff suppressed because it is too large Load Diff

BIN
apps/tevtimer/app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 B

BIN
apps/tevtimer/app.xcf Normal file

Binary file not shown.

View File

@ -0,0 +1,379 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="../../css/spectre.min.css">
<style>
body {
overflow-x: auto;
margin: 0.5em;
}
.timer-block:nth-child(odd) {
background-color: #eee;
}
.timer-block:nth-child(even) {
background-color: #fff;
}
.timer-block div {
padding: 0.2em;
}
.timer-block label {
display: inline-block;
}
.timer-block .vibrate {
width: 5em;
}
.timer-block input[type="number"] {
width: 3em;
}
.timer-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.timer-header button {
margin-left: 0.5em;
}
.btn-move-up, .btn-move-down {
width: 2em;
}
.timer-controls {
display: flex;
justify-content: flex-end;
gap: 0.5em;
}
#main-buttons {
display: flex;
justify-content: space-between;
margin-bottom: 0.5em;
}
</style>
</head>
<body>
<script src="../../core/lib/interface.js"></script>
<div id="content">Loading...</div>
<script>
const DATA_VERSION = 0;
const TIMERS_FILE = 'tevtimer.timers.json';
const APP_FILE = 'tevtimer.app.js';
const MAX_BUZZ_COUNT = 15;
var userTimers = [];
function onInit() {
document.getElementById("content").innerHTML = `
<h1>Timers</h1>
<div id="main-buttons">
<button id="btn-reload-timers" class="btn btn-primary">Reload Timers…</button>
<button id="btn-add-timer" class="btn btn-primary">Add Timer</button>
<button id="btn-save-timers" class="btn btn-primary">Save Timers</button>
</div>
<div id="timerblocks"></div>
`;
document.getElementById('btn-reload-timers').addEventListener('click', reloadTimers);
document.getElementById('btn-add-timer').addEventListener('click', addTimer);
document.getElementById('btn-save-timers').addEventListener('click', saveTimers);
loadTimers();
}
function loadTimers() {
Util.readStorageJSON(TIMERS_FILE, timers => {
userTimers = timers;
setTimeout(updateTimerBlocks, 100);
});
}
function getTimerById(timers, id) {
for (timer of timers) {
if (timer.id == id) {
return timer;
}
}
console.warn(`Timer with ID ${id} not found`);
return null;
}
function find_nextId() {
let maxId = 0;
for (let timer of userTimers) {
if (timer.id > maxId) {
maxId = timer.id;
}
}
return maxId + 1;
}
function splitHMS(hms) {
let h = Math.floor(hms / 3600);
let m = Math.floor((hms % 3600) / 60);
let s = Math.floor(hms % 60);
return [h, m, s];
}
function updateTimerBlocks() {
// Track the currently focused element
const activeElement = document.activeElement;
const activeElementId = activeElement ? activeElement.id : null;
// Re-render the table
document.getElementById('timerblocks').innerHTML = timerBlocks(userTimers);
updateAtEndDropdowns();
// Reattach button handlers
attachButtonHandlers();
// Handle input changes
attachInputHandlers();
// Restore focus to the previously focused element
if (activeElementId) {
let elementToFocus = document.getElementById(activeElementId);
// If the original element no longer exists, focus on a fallback
if (!elementToFocus) {
// Extract the row index
const index = parseInt(activeElementId.split('-')[1], 10);
if (activeElementId.startsWith('delete-') && index < userTimers.length) {
elementToFocus = document.getElementById(`delete-${index}`);
} else if (index > 0) {
elementToFocus = document.getElementById(`delete-${index - 1}`);
}
}
// Restore focus if a valid element is found
if (elementToFocus) {
elementToFocus.focus();
}
}
}
function timerBlocks(timers) {
let blocks = '';
for (let i = 0; i < timers.length; i++) {
let timer = timers[i];
// Assumes timer.rate is 0.001 (seconds), as this
// is the only rate used in the app
if (timer.rate != -0.001) {
console.error('Unsupported timer rate');
continue;
}
let [h, m, s] = splitHMS(timer.origin);
let atEndTimer = timer.chain_id ? getTimerById(timers, timer.chain_id) : null;
let atEndSelected = atEndTimer ? atEndTimer.id : 'null';
blocks += `
<div class="timer-block" id="timer-${i}">
<div class="timer-header">
<span>Timer ${i + 1}</span>
<div class="timer-controls">
${i > 0
? `<button id="move-up-${i}" class="btn btn-primary btn-move-up" title="Move up"></button>`
: ''}
${i < timers.length - 1
? `<button id="move-down-${i}" class="btn btn-primary btn-move-down" title="Move down"></button>`
: ''}
<button id="delete-${i}" class="btn btn-danger btn-delete" title="Delete">🗑️</button>
</div>
</div>
<div class="timer-name">
<label>Name: <input type="text" id="name-${i}" value="${timer.name}" maxlength="25" /></label>
</div>
<div class="timer-start">
<label>Hrs: <input type="number" id="hours-${i}" value="${h}" min="0" max="99" /></label>
<label>Mins: <input type="number" id="minutes-${i}" value="${m}" min="0" max="59" /></label>
<label>Secs: <input type="number" id="seconds-${i}" value="${s}" min="0" max="59" /></label>
</div>
<div class="timer-at-end">
<label>At End:
<select id="atend-${i}"></select>
</label>
</div>
<div class="timer-settings">
<label>Vibrate Pattern: <input type="text" class="vibrate" id="vibrate-${i}" value="${timer.vibrate_pattern}" maxlength="8" /></label>
<label>Buzz Count: <input type="number" id="buzz-${i}" value="${timer.buzz_count}" min="0" max="${MAX_BUZZ_COUNT}" /></label>
</div>
</div>
`;
}
return blocks;
}
function attachButtonHandlers() {
document.querySelectorAll('.btn-move-up').forEach((button, index) => {
button.addEventListener('click', () => moveTimerUp(index + 1));
});
document.querySelectorAll('.btn-move-down').forEach((button, index) => {
button.addEventListener('click', () => moveTimerDown(index));
});
document.querySelectorAll('.btn-delete').forEach((button, index) => {
button.addEventListener('click', () => deleteTimer(index));
});
}
function attachInputHandlers() {
document.querySelectorAll('input[type="text"], input[type="number"], select').forEach((input) => {
input.addEventListener('change', (event) => {
const [type, index] = event.target.id.split('-');
const value = event.target.value;
if (type === 'name') {
userTimers[index].name = value;
updateAtEndDropdowns();
} else if (type === 'hours' || type === 'minutes' || type === 'seconds') {
let hInput = document.getElementById(`hours-${index}`);
let mInput = document.getElementById(`minutes-${index}`);
let sInput = document.getElementById(`seconds-${index}`);
let h = parseInt(hInput.value) || 0;
let m = parseInt(mInput.value) || 0;
let s = parseInt(sInput.value) || 0;
userTimers[index].origin = Math.max(
Math.min(h * 3600 + m * 60 + s, 99 * 3600 + 59 * 60 + 59),
0);
// Normalize the values in case minutes/seconds >59
[h, m, s] = splitHMS(userTimers[index].origin);
hInput.value = h;
mInput.value = m;
sInput.value = s;
} else if (type === 'atend') {
userTimers[index].chain_id = value == 'null' ? null : parseInt(value);
} else if (type === 'vibrate') {
userTimers[index].vibrate_pattern = value;
} else if (type === 'buzz') {
userTimers[index].buzz_count =
Math.max(Math.min(MAX_BUZZ_COUNT, parseInt(value)), 0);
event.target.value = userTimers[index].buzz_count;
}
});
});
}
function moveTimerUp(index) {
if (index > 0) {
[userTimers[index - 1], userTimers[index]] = [userTimers[index], userTimers[index - 1]];
updateTimerBlocks();
// Move focus to the new position of the "Move up" button
const newFocusButton = document.getElementById(`move-up-${index - 1}`);
if (newFocusButton) {
newFocusButton.focus();
}
}
}
function moveTimerDown(index) {
if (index < userTimers.length - 1) {
[userTimers[index], userTimers[index + 1]] = [userTimers[index + 1], userTimers[index]];
updateTimerBlocks();
// Move focus to the new position of the "Move down" button
const newFocusButton = document.getElementById(`move-down-${index + 1}`);
if (newFocusButton) {
newFocusButton.focus();
}
}
}
function deleteTimer(index) {
// Warn user if a timer chain references the timer
for (timer of userTimers) {
if (timer.id != userTimers[index].id &&
timer.chain_id == userTimers[index].id) {
if (!confirm('This timer is part of a chain. Delete it anyway?')) {
return;
}
break;
}
}
if (userTimers.length > 1) {
userTimers.splice(index, 1);
updateTimerBlocks();
}
if (userTimers.length == 1) {
// Disable the last delete button
let deleteButton = document.querySelectorAll('.btn-delete')[0];
deleteButton.disabled = true;
}
}
function addTimer() {
let newTimer = {
cls: "PrimitiveTimer",
version: DATA_VERSION,
origin: 0,
rate: -0.001,
name: "",
id: find_nextId(),
chain_id: null,
start_time: Date.now(),
pause_time: Date.now(),
vibrate_pattern: ";;;",
buzz_count: 4
};
userTimers.push(newTimer);
updateTimerBlocks();
// Enable delete buttons
let deleteButtons = document.querySelectorAll('.btn-delete');
deleteButtons.forEach(button => {
button.disabled = false;
});
// Move focus to the new timer's Name field
document.getElementById(`name-${userTimers.length - 1}`).focus();
}
function saveTimers() {
if (userTimers.length) {
// Guard in case the user manages to click Save before
// the timers are loaded, or something like that
// Ensure timer app is not running while we replace the timer file
Puck.write("if (global.__FILE__=='" + APP_FILE + "')load();\n", () => {
setTimeout(() => {
Util.writeStorage(TIMERS_FILE, JSON.stringify(userTimers), () => {
alert('Timers saved successfully.');
});
}, 2000);
});
};
}
function reloadTimers() {
if (confirm("This will reload timer data from the Bangle.js and discard any unsaved changes. Reload?")) {
loadTimers();
}
}
function updateAtEndDropdowns() {
let timerNames = new Map();
timerNames.set(null, '&lt;Stop&gt;');
userTimers.forEach((timer, i) => {
let name = timer.name ? timer.name : `&lt;Timer ${i + 1}&gt;`;
timerNames.set(timer.id, name);
});
userTimers.forEach((timer, i) => {
let atEndDropdown = document.getElementById(`atend-${i}`);
if (atEndDropdown) {
let atEndSelected = timer.chain_id ? timer.chain_id : 'null';
atEndDropdown.innerHTML = Array.from(timerNames.entries())
.map(([key, value]) =>
`<option value="${key}" ${key == atEndSelected ? 'selected' : ''}>
${value}
</option>`
)
.join('');
}
});
}
</script>
</body>
</html>

482
apps/tevtimer/lib.js Normal file
View File

@ -0,0 +1,482 @@
const Storage = require('Storage');
const Sched = require('sched');
const Time_utils = require('time_utils');
// Convenience functions //
function mod(n, m) {
// Modulus function that works like Python's % operator
return ((n % m) + m) % m;
}
function ceil(value) {
// JavaScript's Math.ceil function is weird, too
// Attempt to work around it
return Math.ceil(Math.round(value * 1e10) / 1e10);
}
// Data models //
class PrimitiveTimer {
// A simple timer object that can be used as a countdown or countup
// timer. It can be paused and resumed, and it can be reset to its
// original value. It can also be saved to and loaded from a
// persistent storage.
constructor(origin, is_running, rate, name, id) {
// origin: initial value of the timer
// is_running: true if the timer should begin running immediately,
// false if it should be paused
// rate: rate of the timer, in units per second. Positive for
// countup, negative for countdown
// name: name of the timer (can be empty)
// id: ID of the timer
this.origin = origin || 0;
// default rate +1 unit per 1000 ms, countup
this.rate = rate || 0.001;
this.name = name || '';
this.id = id || 0;
this.vibrate_pattern = ';;;';
this.buzz_count = 4;
this.chain_id = null;
this._start_time = Date.now();
this._pause_time = is_running ? null : this._start_time;
}
display_name() {
// Return a string to display as the timer name
// If the name is empty, return a generated name
return this.name ? this.name : this.provisional_name();
}
provisional_name() {
// Return a generated name for the timer based on the timer's
// origin and current value
return (
format_duration_2(this.to_msec(this.origin))
+ ' / '
+ format_duration_2(this.to_msec())
);
}
display_status() {
// Return a string representing the timer's status
// (e.g. running, paused, expired)
let status = '';
// Indicate timer expired if its current value is <= 0 and it's
// a countdown timer
if (this.get() <= 0 && this.rate < 0) {
status += '!';
}
if (this.is_running()) {
status += '>';
}
return status;
}
is_running() {
// Return true if the timer is running, false if it is paused
return !this._pause_time;
}
start() {
// Start the timer if it is paused
if (!this.is_running()) {
this._start_time += Date.now() - this._pause_time;
this._pause_time = null;
}
}
pause() {
// Pause the timer if it is running
if (this.is_running()) {
this._pause_time = Date.now();
}
}
reset() {
this.set(this.origin);
}
get() {
// Return the current value of the timer, in rate units
const now = Date.now();
const elapsed =
(now - this._start_time)
- (this.is_running() ? 0 : (now - this._pause_time));
return this.origin + (this.rate * elapsed);
}
set(new_value) {
// Set the timer to a new value, in rate units
const now = Date.now();
this._start_time = (now - new_value / this.rate)
+ (this.origin / this.rate);
if (!this.is_running()) {
this._pause_time = now;
}
}
to_msec(value) {
// Convert given timer value to milliseconds using this.rate
// Uses the current value of the timer if no value is provided
if (value === undefined) {
value = this.get();
}
return Math.ceil(value / Math.abs(this.rate));
}
dump() {
// Serialize the timer object to a JSON-compatible object
return {
cls: 'PrimitiveTimer',
version: 0,
origin: this.origin,
rate: this.rate,
name: this.name,
id: this.id,
chain_id: this.chain_id,
start_time: this._start_time,
pause_time: this._pause_time,
vibrate_pattern: this.vibrate_pattern,
buzz_count: this.buzz_count,
};
}
static load(data) {
// Deserialize a JSON-compatible object to a PrimitiveTimer
// object
if (!(data.cls == 'PrimitiveTimer' && data.version == 0)) {
console.error('Incompatible data type for loading PrimitiveTimer state');
}
let loaded = new this(data.origin, false, data.rate, data.name, data.id);
loaded.chain_id = data.chain_id;
loaded._start_time = data.start_time;
loaded._pause_time = data.pause_time;
loaded.vibrate_pattern = data.vibrate_pattern;
loaded.buzz_count = data.buzz_count;
return loaded;
}
}
function format_duration(msec, have_seconds) {
// Format a duration in milliseconds as a string in HH:MM format
// (have_seconds is false) or HH:MM:SS format (have_seconds is true)
if (msec < 0) {
return '-' + format_duration(-msec, have_seconds);
}
const time = Time_utils.decodeTime(msec);
time.h += time.d * 24;
let str = time.h + ":" + ("0" + time.m).slice(-2);
if (have_seconds) {
str += ":" + ("0" + time.s).slice(-2);
}
return str;
}
function format_duration_2(msec) {
// Like `time_utils.formatDuration`, but handles negative durations
// and returns '0s' instead of an empty string for a duration of zero
let s = Time_utils.formatDuration(Math.abs(msec))
if (s === '') {
return '0s';
}
if (msec < 0) {
return '- ' + s;
}
return s;
}
// Persistent state //
const TIMERS_FILENAME = 'tevtimer.timers.json';
const SETTINGS_FILENAME = 'tevtimer.json';
const SCHEDULED_SAVE_TIMEOUT = 15000;
var SAVE_TIMERS_TIMEOUT = null;
var SAVE_SETTINGS_TIMEOUT = null;
function next_id() {
// Find the next unused ID number for timers
let max_id = 0;
for (let timer of TIMERS) {
if (timer.id > max_id) {
max_id = timer.id;
}
}
return max_id + 1;
}
function find_timer_by_id(id) {
// Return index of timer with ID id, or -1 if not found
for (let idx = 0; idx < TIMERS.length; idx++) {
if (TIMERS[idx].id == id) {
return idx;
}
}
return -1;
}
function load_timers() {
// Load timers from persistent storage
// If no timers are found, create and return a default timer
console.log('loading timers');
let timers = Storage.readJSON(TIMERS_FILENAME, true) || [];
if (timers.length) {
// Deserialize timer objects
timers = timers.map(t => PrimitiveTimer.load(t));
} else {
timers = [new PrimitiveTimer(600, false, -0.001, '', 1)];
timers[0].end_alarm = true;
}
return timers;
}
function save_timers() {
// Save TIMERS to persistent storage
console.log('saving timers');
const dumped_timers = TIMERS.map(t => t.dump());
if (!Storage.writeJSON(TIMERS_FILENAME, dumped_timers)) {
E.showAlert('Trouble saving timers');
}
}
function schedule_save_timers() {
// Schedule a save of the timers to persistent storage
// after a timeout. This is used to reduce the number of
// writes to the flash storage when several changes are
// made in a short time.
if (SAVE_TIMERS_TIMEOUT === null) {
console.log('scheduling timer save');
SAVE_TIMERS_TIMEOUT = setTimeout(() => {
save_timers();
SAVE_TIMERS_TIMEOUT = null;
}, SCHEDULED_SAVE_TIMEOUT);
} else {
console.log('timer save already scheduled');
}
}
function save_settings() {
// Save SETTINGS to persistent storage
console.log('saving settings');
if (!Storage.writeJSON(SETTINGS_FILENAME, SETTINGS)) {
E.showAlert('Trouble saving settings');
}
}
function schedule_save_settings() {
// Schedule a save of the settings to persistent storage
// after a timeout. This is used to reduce the number of
// writes to the flash storage when several changes are
// made in a short time.
if (SAVE_SETTINGS_TIMEOUT === null) {
console.log('scheduling settings save');
SAVE_SETTINGS_TIMEOUT = setTimeout(() => {
save_settings();
SAVE_SETTINGS_TIMEOUT = null;
}, SCHEDULED_SAVE_TIMEOUT);
} else {
console.log('settings save already scheduled');
}
}
// Default settings
// List of actions in menu, in order presented
const ACTIONS = [
'start/stop',
'reset',
'timers',
'edit',
'edit_start',
'format',
];
// Map of action IDs to their UI displayed names
const ACTION_NAMES = {
'start/stop': 'Start/stop',
'reset': 'Reset',
'timers': 'Timers',
'edit': 'Edit timer',
'edit_start': 'Edit start',
'format': 'Format',
};
const SETTINGS = Object.assign({
'format': {
'row1': 'time hh:mm',
'row2': 'start hh:mm:ss',
'row3': 'current hh:mm:ss',
},
'button_act': 'start/stop',
'left_tap_act': 'edit_start',
'right_tap_act': 'edit_start',
'confirm_reset': 'auto',
'confirm_delete': true,
'alarm_return': false,
'auto_reset': false,
}, Storage.readJSON(SETTINGS_FILENAME, true) || {});
var TIMERS = load_timers();
// Persistent data convenience functions
function delete_timer(timers, timer) {
// Find `timer` in array `timers` and remove it.
// Return the next timer in the list, or the last one if `timer`
// was the last one in the list.
const idx = timers.indexOf(timer);
if (idx !== -1) {
timers.splice(idx, 1);
} else {
console.warn('delete_timer: Bug? Tried to delete a timer not in list');
}
// Return another timer to switch UI to after deleting the focused
// one
return timers[Math.min(idx, timers.length - 1)];
}
function add_timer(timers, timer) {
// Create a independent timer object duplicating `timer`, assign it a
// new unique ID, and add it to the top of the array `timers`.
// Return the new timer object.
// This is used to create a new timer from an existing one.
// Create a copy of current timer object
const new_timer = PrimitiveTimer.load(timer.dump());
// Assign a new ID to the timer
new_timer.id = next_id();
// Place it at the top of the list
timers.unshift(new_timer);
return new_timer;
}
function set_last_viewed_timer(timer) {
// Move `timer` to the top of the list of timers, so it will be
// displayed first when the timer list is shown.
const idx = TIMERS.indexOf(timer);
if (idx == -1) {
console.warn('set_last_viewed_timer: Bug? Called with a timer not found in list');
} else if (idx == 0) {
console.debug('set_last_viewed_timer: Already set as last timer');
} else {
// Move timer to top of list
TIMERS.splice(idx, 1);
TIMERS.unshift(timer);
set_timers_dirty();
}
}
function set_timers_dirty() {
// Mark the timers as modified and schedule a write to
// persistent storage.
setTimeout(update_system_alarms, 500);
schedule_save_timers();
}
function set_settings_dirty() {
// Mark the settings as modified and schedule a write to
// persistent storage.
schedule_save_settings();
}
// Alarm handling //
function delete_system_alarms() {
// Delete system alarms associated with the tevtimer app (except those
// that are snoozed, so that they will trigger later)
var alarms = Sched.getAlarms().filter(a => a.appid == 'tevtimer');
for (let alarm of alarms) {
if (alarm.ot === undefined) {
console.debug('delete_system_alarms: delete sched alarm ' + alarm.id);
Sched.setAlarm(alarm.id, undefined);
} else {
// Avoid deleting timers awaiting snoozing
console.debug('delete_system_alarms: skipping snoozed alarm ' + alarm.id);
}
}
Sched.reload();
}
function set_system_alarms() {
// Set system alarms (via `sched` app) for running countdown timers
// that will expire in the future.
for (let idx = 0; idx < TIMERS.length; idx++) {
let timer = TIMERS[idx];
let time_to_next_alarm = timer.to_msec();
if (timer.is_running() && time_to_next_alarm > 0) {
console.debug('set_system_alarms: set sched alarm ' + timer.id
+ ' (' + time_to_next_alarm + ' ms)');
Sched.setAlarm(timer.id, {
appid: 'tevtimer',
timer: time_to_next_alarm,
msg: '',
js: "load('tevtimer.alarm.js');",
as: true, // Allow auto-snooze if not immediately dismissed
});
}
}
Sched.reload();
}
function update_system_alarms() {
// Refresh system alarms (`sched` app) to reflect changes to timers
delete_system_alarms();
set_system_alarms();
}
// Make sure we save timers and settings when switching to another app
// or rebooting
E.on('kill', () => { save_timers(); });
E.on('kill', () => { save_settings(); });
exports = {TIMERS, SETTINGS, ACTIONS, ACTION_NAMES,
mod, ceil,
next_id, find_timer_by_id,
load_timers, save_timers, schedule_save_timers, save_settings, schedule_save_settings,
PrimitiveTimer,
format_duration, format_duration_2,
delete_timer, add_timer, set_last_viewed_timer, set_timers_dirty, set_settings_dirty,
update_system_alarms};

View File

@ -0,0 +1,24 @@
{
"id": "tevtimer",
"name": "tev's timer",
"shortName":"tev's timer",
"icon": "app.png",
"version": "0.01",
"description": "A countdown timer app with interval and repeat features",
"screenshots": [ {"url": "screenshot.png" } ],
"readme": "README.md",
"tags": "timer",
"interface": "interface.html",
"supports": ["BANGLEJS2"],
"dependencies": {"scheduler": "type"},
"storage": [
{"name": "tevtimer.app.js", "url": "app.js"},
{"name": "tevtimer.alarm.js", "url": "alarm.js"},
{"name": "tevtimer", "url": "lib.js"},
{"name": "tevtimer.img", "url": "app-icon.js", "evaluate": true}
],
"data": [
{"name": "tevtimer.json"},
{"name": "tevtimer.timers.json"}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB