commit
6cc628f173
|
|
@ -0,0 +1 @@
|
|||
0.01: Official release
|
||||
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
require("heatshrink").decompress(atob("mEwwcCpMgBo8EyVJkgCECKMki9duvXAQVcBwwCCwXr126AQXhCJOQEYsJCJQjFCJWSjojD6AOIAQVDEYeCCJdBuvAQI8SCItIl2ACJ1JgOAjlxNwdduA4HhkwgeONwfrwgRHkOGgC2PmHDSQnoCJMGjAjEoIRJwEBhy2OCIMF66AHgAREnHIkG6CJsWjkkhMg+fHSQd14QRDkG45eEIYMevSSD1y2EgvWjtwwMkwa2KhwjB0GQki2F0DpEuojBukQkmREYkECIdDa4PDhkCgEEnS2IoFhw0YsOCCIMXbREDEYcw8+gwQjC8IRDyAgBEYWAjnwyAMCZAmQEAQCBgHn10QCI4gCAQNgg8c+vCTgMgYQhEDAQM4EYOCoVJgTCEbwkB44jB6ARMBQkIgDUEAQoRQpAKEkfOwQRIoAHEkF54QROg/O3ARIeIlInn566hEWwZMF8/O3QRHwCBEWwN569BCJgtJAQ4RQkJjJAQxUFQ4oRWpDPLAQlCeZYCEQAgADCKI"))
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 827 B |
Binary file not shown.
|
|
@ -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, '<Stop>');
|
||||
userTimers.forEach((timer, i) => {
|
||||
let name = timer.name ? timer.name : `<Timer ${i + 1}>`;
|
||||
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>
|
||||
|
|
@ -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};
|
||||
|
|
@ -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 |
Loading…
Reference in New Issue