diff --git a/apps/tevtimer/ChangeLog b/apps/tevtimer/ChangeLog new file mode 100644 index 000000000..a8d810cea --- /dev/null +++ b/apps/tevtimer/ChangeLog @@ -0,0 +1 @@ +0.01: Official release diff --git a/apps/tevtimer/README.md b/apps/tevtimer/README.md new file mode 100644 index 000000000..ed57699c9 --- /dev/null +++ b/apps/tevtimer/README.md @@ -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. diff --git a/apps/tevtimer/alarm.js b/apps/tevtimer/alarm.js new file mode 100644 index 000000000..3b48e24d2 --- /dev/null +++ b/apps/tevtimer/alarm.js @@ -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); +} diff --git a/apps/tevtimer/app-icon.js b/apps/tevtimer/app-icon.js new file mode 100644 index 000000000..68b44efc6 --- /dev/null +++ b/apps/tevtimer/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwcCpMgBo8EyVJkgCECKMki9duvXAQVcBwwCCwXr126AQXhCJOQEYsJCJQjFCJWSjojD6AOIAQVDEYeCCJdBuvAQI8SCItIl2ACJ1JgOAjlxNwdduA4HhkwgeONwfrwgRHkOGgC2PmHDSQnoCJMGjAjEoIRJwEBhy2OCIMF66AHgAREnHIkG6CJsWjkkhMg+fHSQd14QRDkG45eEIYMevSSD1y2EgvWjtwwMkwa2KhwjB0GQki2F0DpEuojBukQkmREYkECIdDa4PDhkCgEEnS2IoFhw0YsOCCIMXbREDEYcw8+gwQjC8IRDyAgBEYWAjnwyAMCZAmQEAQCBgHn10QCI4gCAQNgg8c+vCTgMgYQhEDAQM4EYOCoVJgTCEbwkB44jB6ARMBQkIgDUEAQoRQpAKEkfOwQRIoAHEkF54QROg/O3ARIeIlInn566hEWwZMF8/O3QRHwCBEWwN569BCJgtJAQ4RQkJjJAQxUFQ4oRWpDPLAQlCeZYCEQAgADCKI")) diff --git a/apps/tevtimer/app.js b/apps/tevtimer/app.js new file mode 100644 index 000000000..83b2e51fe --- /dev/null +++ b/apps/tevtimer/app.js @@ -0,0 +1,1324 @@ +const Layout = require('Layout'); +const locale = require('locale'); +const pickers = require('more_pickers'); + +const tt = require('tevtimer'); + + +// UI // + +// Length of time displaying timer view before moving timer to top of +// timer list +const MOVE_TO_TOP_TIMEOUT = 5000; + +// Min number of pixels of movement to recognize a touchscreen drag/swipe +const DRAG_THRESHOLD = 50; + +// Physical left/right button size in UI +const ARROW_BTN_SIZE = 15; + +// IDs of main screen labels +const ROW_IDS = ['row1', 'row2', 'row3']; + +// Fonts to use for each screen label and display format +const FONT = { + 'row1': { + 'start hh:mm:ss': '12x20', + 'current hh:mm:ss': '12x20', + 'time hh:mm:ss': '12x20', + + 'start hh:mm': '12x20', + 'current hh:mm': '12x20', + 'time hh:mm': '12x20', + + 'start auto': '12x20', + 'current auto': '12x20', + 'time auto': '12x20', + + 'name': '12x20', + + 'format-menu': '12x20', + }, + + 'row2': { + 'start hh:mm:ss': 'Vector:34x42', + 'current hh:mm:ss': 'Vector:34x42', + 'time hh:mm:ss': 'Vector:24x42', + + 'start hh:mm': 'Vector:48x42', + 'current hh:mm': 'Vector:48x42', + 'time hh:mm': 'Vector:56x42', + + 'start auto': 'Vector:34x42', + 'current auto': 'Vector:34x42', + 'time auto': 'Vector:24x42', + + 'name': 'Vector:24x42', + + 'format-menu': 'Vector:26x42', + }, + + 'row3': { + 'start hh:mm:ss': 'Vector:34x56', + 'current hh:mm:ss': 'Vector:34x56', + 'time hh:mm:ss': 'Vector:24x56', + + 'start hh:mm': 'Vector:48x56', + 'current hh:mm': 'Vector:48x56', + 'time hh:mm': 'Vector:56x56', + + 'start auto': 'Vector:34x56', + 'current auto': 'Vector:34x56', + 'time auto': 'Vector:24x56', + + 'name': 'Vector:24x56', + + 'format-menu': 'Vector:26x56', + } +}; + +// List of format IDs available in the format menu +// (in the order they are displayed in the menu) +const FORMAT_MENU = [ + 'start auto', + 'start hh:mm:ss', + 'start hh:mm', + 'current auto', + 'current hh:mm:ss', + 'current hh:mm', + 'time auto', + 'time hh:mm:ss', + 'time hh:mm', + 'name', +]; + +// Mapping of format IDs to their human-friendly names displayed in the +// format menu +const FORMAT_DISPLAY = { + 'start auto': 'Start Auto', + 'start hh:mm:ss': 'Start HMS', + 'start hh:mm': 'Start HM', + 'current auto': 'Curr Auto', + 'current hh:mm:ss': 'Curr HMS', + 'current hh:mm': 'Curr HM', + 'time auto': 'Time Auto', + 'time hh:mm:ss': 'Time HMS', + 'time hh:mm': 'Time HM', + 'name': 'Name', +}; + + +function row_font(row_name, format) { + // Convenience function to retrieve the font ID for the given display + // field and format mode + + let font = FONT[row_name][format]; + if (font === undefined) { + console.error('Unknown font for row_font("' + row_name + '", "' + format + '")'); + return '12x20'; + } + return font; +} + + +function next_time_update(interval, curr_time, direction) { + // Determine time in milliseconds until next display update for a timer + // that should be updated every `interval` milliseconds. + // + // `curr_time` is the current time in milliseconds, and `direction` + // is either 1 (forward) or -1 (backward). The function returns the + // time in milliseconds until the next update, or Infinity if there + // is no update needed (e.g. if interval is zero or negative). + + if (interval <= 0) { + // Don't update if interval is zero or negative + return Infinity; + } + + // Find the next time we should update the display + let next_update = tt.mod(curr_time, interval); + if (direction < 0) { + next_update = 1 - next_update; + } + if (next_update < 0) { + // Handle negative modulus + next_update += interval; + } + next_update = interval - next_update; + + // Add compensating factor of 50ms due to timeouts apparently + // sometimes triggering too early. + return next_update + 50; +} + + +function draw_triangle(lay, flip) { + // Render right-pointing triangle if `flip`, else left-pointing + // triangle + + flip = flip ? lay.width : 0; + g.setColor(g.theme.fg2) + .fillPoly([flip + lay.x, lay.y + lay.height / 2, + lay.x + lay.width - flip, lay.y, + lay.x + lay.width - flip, lay.y + lay.height]); +} + + +function update_status_widget(timer) { + // Update the status widget with the current timer status. The + // timer is passed as a parameter. + + function widget_draw() { + // Draw a right-pointing arrow if the timer is running + + g.reset(); + if (WIDGETS.tevtimer.width > 1) { + draw_triangle({ + x: WIDGETS.tevtimer.x, + // Center the arrow vertically in the 24-pixel-height widget area + y: WIDGETS.tevtimer.y + Math.floor((24 - ARROW_BTN_SIZE) / 2), + width: ARROW_BTN_SIZE, + height: ARROW_BTN_SIZE + }, true); + } + } + + // For some reason, a width of 0 when there's nothing to display + // doesn't work as expected, so we use 1 instead. + let width = timer.is_running() ? ARROW_BTN_SIZE : 1; + + if (WIDGETS.tevtimer === undefined) { + WIDGETS.tevtimer = { + area: 'tr', + draw: widget_draw, + }; + } + WIDGETS.tevtimer.width = width; + Bangle.drawWidgets(); +} + + +// UI modes // + +class TimerView { + // Primary UI for displaying and operating a timer. The + // PrimitiveTimer object is passed to the constructor as a + // parameter. + + constructor(timer) { + this.timer = timer; + + this.layout = null; + this.listeners = {}; + this.listeners.timer_render_timeout = null; + } + + start() { + // Initialize, display, and activate the UI + + this._initLayout(); + this.layout.update(); + this.layout.clear(); + this.render(); + + // Physical button handler + this.listeners.button = setWatch( + () => { this.dispatch_action(tt.SETTINGS.button_act); }, + BTN, + {edge: 'falling', debounce: 50, repeat: true} + ); + + // Tap handler + function tapHandler(button, xy) { + // Check if the tap was in the area of the timer display + if (xy.y < this.layout.buttons.y) { + // Dispatch action specified based on left or right half of display tapped + if (xy.x < this.layout.row1.x + this.layout.row1.w / 2) { + this.dispatch_action(tt.SETTINGS.left_tap_act); + } else { + this.dispatch_action(tt.SETTINGS.right_tap_act); + } + } + } + this.listeners.tap = tapHandler.bind(this); + Bangle.on('touch', this.listeners.tap); + + // Drag handler + let distanceX = null; + function dragHandler(ev) { + if (ev.b) { + if (distanceX === null) { + // Drag started + distanceX = ev.dx; + } else { + // Drag in progress + distanceX += ev.dx; + } + } else { + // Drag released + distanceX = null; + } + if (Math.abs(distanceX) > DRAG_THRESHOLD) { + // Horizontal scroll threshold reached + Bangle.buzz(50, 0.5); + // Switch UI view to next or previous timer in list based on + // sign of distanceX + let new_index = tt.TIMERS.indexOf(this.timer) + Math.sign(distanceX); + switch_UI(new TimerView(tt.TIMERS[ + tt.mod(new_index, tt.TIMERS.length) + ])); + distanceX = null; + } + } + this.listeners.drag = dragHandler.bind(this); + Bangle.on('drag', this.listeners.drag); + + // Auto move-to-top on use handler + this.listeners.to_top_timeout = setTimeout( + tt.set_last_viewed_timer, MOVE_TO_TOP_TIMEOUT, this.timer); + + // Screen lock/unlock handler + function lockHandler() { + // If 'current auto' is an active format, update the timer + // display + for (var id of ROW_IDS) { + if (tt.SETTINGS.format[id] == 'current auto') { + this.render('timer'); + break; + } + } + } + this.listeners.lock = lockHandler.bind(this); + Bangle.on('lock', this.listeners.lock); + } + + stop() { + // Shut down the UI and clean up listeners and handlers + + if (this.listeners.timer_render_timeout !== null) { + clearTimeout(this.listeners.timer_render_timeout); + this.listeners.timer_render_timeout = null; + } + clearWatch(this.listeners.button); + Bangle.removeListener('touch', this.listeners.tap); + Bangle.removeListener('drag', this.listeners.drag); + clearTimeout(this.listeners.to_top_timeout); + Bangle.removeListener('lock', this.listeners.lock); + Bangle.setUI(); + } + + _initLayout() { + const layout = new Layout( + { + type: 'v', + bgCol: g.theme.bg, + c: [ + { + type: 'txt', + id: 'row1', + label: '', + font: row_font('row1', tt.SETTINGS.format.row1), + fillx: 1, + }, + { + type: 'txt', + id: 'row2', + label: '', + font: row_font('row2', tt.SETTINGS.format.row2), + fillx: 1, + }, + { + type: 'txt', + id: 'row3', + label: '', + font: row_font('row3', tt.SETTINGS.format.row3), + fillx: 1, + }, + { + type: 'h', + id: 'buttons', + c: [ + {type: 'btn', font: '6x8:2', fillx: 1, label: 'St/Pa', id: 'start_btn', + cb: this.start_stop_timer.bind(this)}, + {type: 'btn', font: '6x8:2', fillx: 1, label: 'Menu', id: 'menu_btn', + cb: () => { + switch_UI(new TimerViewMenu(this.timer)); + } + } + ] + } + ] + } + ); + this.layout = layout; + } + + render(item) { + // Draw the timer display and update the status and buttons. The + // `item` parameter specifies which part of the display to update. + // If `item` is not specified, the entire display is updated. + + console.debug('render called: ' + item); + + if (!item) { + this.layout.update(); + } + + if (!item || item == 'timer') { + + let update_interval = Infinity; + + for (var id of ROW_IDS) { + const elem = this.layout[id]; + const running = this.timer.is_running(); + + let format = tt.SETTINGS.format[id]; + // Special handling for “auto” formats + if (format == 'start auto') { + format = Bangle.isLocked() ? 'start hh:mm' : 'start hh:mm:ss'; + } else if (format == 'current auto') { + format = Bangle.isLocked() ? 'current hh:mm' : 'current hh:mm:ss'; + } else if (format == 'time auto') { + format = Bangle.isLocked() ? 'time hh:mm' : 'time hh:mm:ss'; + } + + if (format == 'start hh:mm:ss') { + elem.label = tt.format_duration(this.timer.to_msec(this.timer.origin), true); + + } else if (format == 'current hh:mm:ss') { + elem.label = tt.format_duration(this.timer.to_msec(), true); + if (running) { + update_interval = Math.min( + update_interval, + next_time_update(1000, this.timer.to_msec(), this.timer.rate) + ); + } + + } else if (format == 'time hh:mm:ss') { + elem.label = locale.time(new Date()).trim(); + update_interval = Math.min( + update_interval, + next_time_update(1000, Date.now(), 1) + ); + + } else if (format == 'start hh:mm') { + elem.label = tt.format_duration(this.timer.to_msec(this.timer.origin), false); + + } else if (format == 'current hh:mm') { + elem.label = tt.format_duration(this.timer.to_msec(), false); + if (running) { + // Update every minute for current HM when running + update_interval = Math.min( + update_interval, + next_time_update(60000, this.timer.to_msec(), this.timer.rate) + ); + } + + } else if (format == 'time hh:mm') { + elem.label = locale.time(new Date(), 1).trim(); + update_interval = Math.min( + update_interval, + next_time_update(60000, Date.now(), 1) + ); + + } else if (format == 'name') { + elem.label = this.timer.display_name(); + } + + elem.font = row_font(id, format); + this.layout.clear(elem); + this.layout.render(elem); + } + + + if (this.listeners.timer_render_timeout) { + clearTimeout(this.listeners.timer_render_timeout); + this.listeners.timer_render_timeout = null; + } + + // Set up timeout to render timer again when needed + if (update_interval !== Infinity) { + console.debug('Next render update scheduled in ' + update_interval + ' ms'); + this.listeners.timer_render_timeout = setTimeout( + () => { + this.listeners.timer_render_timeout = null; + this.render('timer'); + }, + update_interval + ); + } + } + + if (!item || item == 'status') { + this.layout.start_btn.label = + this.timer.is_running() ? 'Pause' : 'Start'; + this.layout.render(this.layout.buttons); + update_status_widget(this.timer); + } + } + + start_stop_timer() { + // Start or pause the timer + + if (this.timer.is_running()) { + this.timer.pause(); + } else { + this.timer.start(); + } + tt.set_timers_dirty(); + this.render('status'); + this.render('timer'); + } + + dispatch_action(action) { + // Execute a UI action represented by the string `action`. + + if (action === 'start/stop') { + this.start_stop_timer() + + } else if (action === 'reset') { + switch_UI( + new ResetTimer( + this.timer, + () => { switch_UI(new TimerView(this.timer)); } + ) + ); + + } else if (action === 'timers') { + switch_UI( + new TimerMenu( + tt.TIMERS, + this.timer, + (timer, focused_timer) => { + switch_UI(new TimerView(timer || focused_timer)); + } + ) + ); + + } else if (action == 'edit') { + switch_UI(new TimerEditMenu( + this.timer, + () => { switch_UI(new TimerView(this.timer)); } + )); + + } else if (action === 'edit_start') { + switch_UI(new TimerEditStart( + this.timer, + () => { switch_UI(new TimerView(this.timer)); } + )); + + } else if (action == 'format') { + switch_UI(new TimerFormatView( + this.timer, + () => { switch_UI(new TimerView(this.timer)); } + )); + + } + } +} + + +class TimerFormatView { + // UI for selecting the display format of a timer. + + constructor(timer, back) { + // `timer` is the current PrimitiveTimer object being edited. + // `back` is the function that activates the previous UI to return + // to when the format selection is exited. It is passed `true` if + // the format change was confirmed and `false` if it was canceled. + // If `back` is not specified, a default back handler is used that + // returns to the TimerView if accepted or TimerViewMenu if + // canceled. + + this.timer = timer; + this.back = back || this._back; + + this.layout = null; + this.listeners = {}; + + // Get format name indeces for UI + this.format_idx = {}; + for (var row_id of ROW_IDS) { + let idx = FORMAT_MENU.indexOf(tt.SETTINGS.format[row_id]); + if (idx === -1) { + console.warn('Unknown format "' + tt.SETTINGS.format[row_id] + '"'); + idx = 0; + } + this.format_idx[row_id] = idx; + } + } + + _back(ok) { + // Default back handler + if (ok) { + switch_UI(new TimerView(this.timer)); + } else { + switch_UI(new TimerViewMenu(this.timer)); + } + } + + start() { + // Initialize, display, and activate the UI + + this._initLayout(); + this.layout.update(); + this.layout.clear(); + this.render(); + + // Drag handler + let distanceX = null; + function dragHandler(ev) { + if (ev.b) { + if (distanceX === null) { + // Drag started + distanceX = ev.dx; + } else { + // Drag in progress + distanceX += ev.dx; + } + } else { + // Drag released + distanceX = null; + } + if (Math.abs(distanceX) > DRAG_THRESHOLD) { + // Horizontal drag threshold reached + // Increment or decrement row's format index based on sign of + // distanceX + for (var row_id of ROW_IDS) { + if (ev.y < this.layout[row_id].y + this.layout[row_id].h) { + Bangle.buzz(50, 0.5); + if (Math.sign(distanceX) > 0) { + this.incr_format_idx(row_id); + } else { + this.decr_format_idx(row_id); + } + distanceX = null; + break; + } + } + } + } + this.listeners.drag = dragHandler.bind(this); + Bangle.on('drag', this.listeners.drag); + + // Touch handler + function touchHandler(button, xy) { + // Increment or decrement row's format index based on the arrow tapped + + // Enlarge tap area by this amount in the X direction to make it + // easier to hit + const x_tolerance = 20; + + for (let row_id of ROW_IDS) { + for (let btn_id of ['prev', 'next']) { + let elem = row_id + '.' + btn_id; + if (xy.x >= this.layout[elem].x - x_tolerance + && xy.x <= this.layout[elem].x + this.layout[elem].w + x_tolerance + && xy.y >= this.layout[elem].y + && xy.y <= this.layout[elem].y + this.layout[elem].h) { + if (btn_id === 'prev') { + this.decr_format_idx(row_id); + } else { + this.incr_format_idx(row_id); + } + break; + } + } + } + } + this.listeners.touch = touchHandler.bind(this); + Bangle.on('touch', this.listeners.touch); + + // Physical button handler + this.listeners.button = setWatch( + this.cancel.bind(this), + BTN, + {edge: 'falling', debounce: 50, repeat: true} + ); + } + + stop() { + // Shut down the UI and clean up listeners and handlers + + Bangle.removeListener('drag', this.listeners.drag); + Bangle.removeListener('touch', this.listeners.touch); + clearWatch(this.listeners.button); + } + + _initLayout() { + const layout = new Layout( + { + type: 'v', + bgCol: g.theme.bg, + c: [ + { + type: 'h', + c: [ + { + type: 'custom', + id: 'row1.prev', + render: lay => draw_triangle(lay, false), + width: ARROW_BTN_SIZE, + height: ARROW_BTN_SIZE, + }, + { + type: 'txt', + id: 'row1', + label: FORMAT_DISPLAY[FORMAT_MENU[this.format_idx.row1]], + font: row_font('row1', 'format-menu'), + fillx: 1, + }, + { + type: 'custom', + id: 'row1.next', + render: lay => draw_triangle(lay, true), + width: ARROW_BTN_SIZE, + height: ARROW_BTN_SIZE, + }, + ], + }, + { + type: 'h', + c: [ + { + type: 'custom', + id: 'row2.prev', + render: lay => draw_triangle(lay, false), + width: ARROW_BTN_SIZE, + height: ARROW_BTN_SIZE, + }, + { + type: 'txt', + id: 'row2', + label: FORMAT_DISPLAY[FORMAT_MENU[this.format_idx.row2]], + font: row_font('row2', 'format-menu'), + fillx: 1, + }, + { + type: 'custom', + id: 'row2.next', + render: lay => draw_triangle(lay, true), + width: ARROW_BTN_SIZE, + height: ARROW_BTN_SIZE, + }, + ], + }, + { + type: 'h', + c: [ + { + type: 'custom', + id: 'row3.prev', + render: lay => draw_triangle(lay, false), + width: ARROW_BTN_SIZE, + height: ARROW_BTN_SIZE, + }, + { + type: 'txt', + id: 'row3', + label: FORMAT_DISPLAY[FORMAT_MENU[this.format_idx.row3]], + font: row_font('row3', 'format-menu'), + fillx: 1, + }, + { + type: 'custom', + id: 'row3.next', + render: lay => draw_triangle(lay, true), + width: ARROW_BTN_SIZE, + height: ARROW_BTN_SIZE, + }, + ], + }, + { + type: 'h', + id: 'buttons', + c: [ + {type: 'btn', font: '6x8:2', fillx: 1, label: 'Cancel', id: 'cancel_btn', + cb: () => { this.cancel(); } + }, + {type: 'btn', font: '6x8:2', fillx: 1, label: 'OK', id: 'ok_btn', + cb: () => { this.ok(); } + }, + ] + } + ] + } + ); + this.layout = layout; + } + + render() { + // Draw the format selection UI. + + this.layout.render(); + } + + update_row(row_id) { + // Render the display format for the given row ID. The row ID + // should be one of 'row1', 'row2', or 'row3'. + + const elem = this.layout[row_id]; + elem.label = FORMAT_DISPLAY[FORMAT_MENU[this.format_idx[row_id]]]; + this.layout.clear(elem); + this.layout.render(elem); + } + + incr_format_idx(row_id) { + // Increment the selected format for the given row ID. The row ID + // should be one of 'row1', 'row2', or 'row3'. + + this.format_idx[row_id] += 1; + if (this.format_idx[row_id] >= FORMAT_MENU.length) { + this.format_idx[row_id] = 0; + } + this.update_row(row_id); + } + + decr_format_idx(row_id) { + // Decrement the selected format for the given row ID. The row ID + // should be one of 'row1', 'row2', or 'row3'. + + this.format_idx[row_id] -= 1; + if (this.format_idx[row_id] < 0) { + this.format_idx[row_id] = FORMAT_MENU.length - 1; + } + this.update_row(row_id); + } + + ok() { + // Save new format settings and return to TimerView + for (var row_id of ROW_IDS) { + tt.SETTINGS.format[row_id] = FORMAT_MENU[this.format_idx[row_id]]; + } + tt.set_settings_dirty(); + this.back(true); + } + + cancel() { + // Return to TimerViewMenu without saving changes + this.back(false); + } +} + + +class TimerViewMenu { + // UI for displaying the timer menu. + + constructor(timer, back) { + // `timer` is the PrimitiveTimer object whose menu is being + // displayed. `back` is a function that activates the previous UI + // to return to when the menu is exited. + + this.timer = timer; + this.back = back || this._back; + } + + _back() { + // Default back handler + // Return to TimerView for the current timer + switch_UI(new TimerView(this.timer)); + } + + start() { + // Display and activate the timer view menu. + + const menu = { + '': { + title: this.timer.display_name(), + back: (() => { this.back(); }), + }, + 'Reset': () => { switch_UI(new ResetTimer(this.timer)); }, + 'Timers': () => { switch_UI(new TimerMenu(tt.TIMERS, this.timer)); }, + 'Edit': () => { switch_UI(new TimerEditMenu(this.timer)); }, + 'Format': () => { switch_UI(new TimerFormatView(this.timer)); }, + 'Add': () => { + tt.set_timers_dirty(); + const new_timer = tt.add_timer(tt.TIMERS, this.timer); + switch_UI(new TimerEditMenu(new_timer)); + }, + 'Delete': () => { switch_UI(new DeleteTimer(this.timer)); }, + 'Settings': () => { switch_UI(new AppSettingsMenu(this.timer)); }, + }; + if (tt.TIMERS.length <= 1) { + // Prevent user deleting last timer + delete menu.Delete; + } + + E.showMenu(menu); + } + + stop() { + // Shut down the UI and clean up listeners and handlers + + E.showMenu(); + } + +} + +class ResetTimer { + // UI for resetting a timer. + + constructor(timer, back) { + // `timer` is the PrimitiveTimer object to reset. + // `back` is a function that activates the previous UI to return + // to when the menu is exited. It is passed `true` if the timer is + // reset and `false` if it is canceled. If `back` is not + // specified, a default back handler is used that returns to + // TimerView if accepted or TimerViewMenu if canceled. + + this.timer = timer; + this.back = back || this._back; + } + + _back(ok) { + // Default back handler + + if (ok) { + switch_UI(new TimerView(this.timer)); + } else { + switch_UI(new TimerViewMenu(this.timer)); + } + } + + start() { + // Display and activate the reset timer confirmation menu if + // configured in settings, or immediately reset the timer if not. + + const menu = { + '': { + title: 'Confirm reset', + back: () => { this.back(false); } + }, + 'Reset': () => { + this.timer.reset(); + tt.set_timers_dirty(); + this.back(true); + }, + 'Cancel': () => { this.back(false); }, + }; + + if (tt.SETTINGS.confirm_reset === true + || (tt.SETTINGS.confirm_reset === 'auto' + && this.timer.to_msec() > 0)) { + E.showMenu(menu); + } else { + menu.Reset(); + } + } + + stop() { + // Shut down the UI and clean up listeners and handlers + + E.showMenu(); + } +} + +class DeleteTimer { + // UI for deleting a timer. + + constructor(timer, back) { + // `timer` is the PrimitiveTimer object to delete. `back` is a + // function that activates the previous UI to return to when the + // menu is exited. It is passed `true` for the first parameter if + // the timer is deleted and `false` if it is canceled. For the + // second parameter, it is passed the same timer if canceled or + // another existing timer in the list if the given timer was + // deleted. If `back` is not specified, a default back handler is + // used that returns to TimerView if accepted or TimerViewMenu if + // canceled. + + this.timer = timer; + this.back = back || this._back; + } + + _back(ok, timer) { + // Default back handler + + if (ok) { + switch_UI(new TimerView(timer)); + } else { + switch_UI(new TimerViewMenu(timer)); + } + } + + start() { + // Display and activate the delete timer confirmation menu if + // configured in settings, or immediately delete the timer if + // not. + + const menu = { + '': { + title: 'Confirm delete', + back: () => { this.back(false, this.timer); } + }, + 'Delete': () => { + tt.set_timers_dirty(); + this.back(true, tt.delete_timer(tt.TIMERS, this.timer)); + }, + 'Cancel': () => { this.back(false, this.timer) }, + }; + + if (tt.SETTINGS.confirm_delete) { + E.showMenu(menu); + } else { + menu.Delete(); + } + + } + + stop() { + // Shut down the UI and clean up listeners and handlers + + E.showMenu(); + } +} + +class TimerEditMenu { + // UI for editing a timer. + + constructor(timer, back) { + // `timer` is the PrimitiveTimer object to edit. `back` is a + // function that activates the previous UI to return to when the + // menu is exited. If `back` is not specified, a default back + // handler is used that returns to TimerViewMenu. + + this.timer = timer; + this.back = back || this._back; + } + + _back() { + // Default back handler + + switch_UI(new TimerViewMenu(this.timer)); + } + + start() { + // Display the edit menu for the timer. + + let keyboard = null; + try { keyboard = require("textinput"); } catch (e) {} + + const menu = { + '': { + title: 'Edit: ' + this.timer.display_name(), + back: () => { this.back(); } + }, + 'Name': { + value: this.timer.name, + onchange: () => { + setTimeout(() => { + keyboard.input({text:this.timer.name}).then(text => { + this.timer.name = text; + tt.set_timers_dirty(); + switch_UI(this); + }); + }, 0); + } + }, + 'Start': () => { + switch_UI(new TimerEditStart( + this.timer, + () => { switch_UI(this); } + ) + ); + }, + 'At end': { + // Option to auto-start another timer when this one ends + format: v => v === -1 + ? "Stop" + : tt.TIMERS[v].display_status() + + ' ' + + tt.TIMERS[v].display_name(), + value: tt.find_timer_by_id(this.timer.chain_id), + min: -1, + max: tt.TIMERS.length - 1, + onchange: v => { + this.timer.chain_id = v === -1 ? null : tt.TIMERS[v].id; + tt.set_timers_dirty(); + } + }, + 'Vibrate pattern': require("buzz_menu").pattern( + this.timer.vibrate_pattern, + v => this.timer.vibrate_pattern = v), + 'Buzz count': { + value: this.timer.buzz_count, + min: 0, + max: 15, + step: 1, + wrap: true, + format: v => v === 0 ? "Forever" : v, + onchange: v => { + this.timer.buzz_count = v; + tt.set_timers_dirty(); + }, + }, + }; + + if (!keyboard) { + // Hide the Name menu item if text input module is not available + delete menu.Name; + } + + E.showMenu(menu); + } + + stop() { + // Shut down the UI and clean up listeners and handlers + + E.showMenu(); + } +} + + +class TimerEditStart { + // UI for editing the timer's starting value. + + constructor(timer, back) { + // `timer` is the PrimitiveTimer object to edit. `back` is a + // function that activates the previous UI to return to when the + // menu is exited. It is passed `true` if the timer is edited and + // `false` if it is canceled. If `back` is not specified, a + // default back handler is used that returns to TimerView. + + this.timer = timer; + this.back = back || this._back; + } + + _back(ok) { + // Default back handler + + switch_UI(new TimerEditMenu(this.timer)); + } + + start() { + // Display the edit > start menu for the timer + + var ok = false; + + let origin_hms = { + h: Math.floor(this.timer.origin / 3600), + m: Math.floor(this.timer.origin / 60) % 60, + s: Math.floor(this.timer.origin % 60), + }; + + function picker_format(v) { + // Display leading 0 for single digit values in the picker + return v < 10 ? '0' + v : v; + } + + pickers.triplePicker({ + title: "Set Start", + value_1: origin_hms.h, + value_2: origin_hms.m, + value_3: origin_hms.s, + format_2: picker_format, + format_3: picker_format, + min_1: 0, + max_1: 99, + min_2: 0, + max_2: 59, + min_3: 0, + max_3: 59, + wrap_1: false, + wrap_2: true, + wrap_3: true, + separator_1: ':', + separator_2: ':', + back: () => { this.back(ok); }, + onchange: (h, m, s) => { + ok = true; + this.timer.origin = h * 3600 + m * 60 + s; + tt.set_timers_dirty(); + } + }); + } + + stop() { + // Shut down the UI and clean up listeners and handlers + + E.showMenu(); + } +} + + +class TimerMenu { + // UI for choosing among the list of defined timers. + + constructor(timers, focused_timer, back) { + // `timers` is the list of PrimitiveTimer objects to display. + // `focused_timer` is the PrimitiveTimer object that is currently + // being displayed. `back` is a function that activates the + // previous UI to return to when the menu is exited. It is passed + // the selected timer object if a timer is selected or `null` if + // the menu is canceled, and the last-focused timer object. If not + // specified, a default back handler is used that returns to + // TimerView for the selected timer or TimerViewMenu if canceled. + + this.timers = timers; + this.focused_timer = focused_timer; + this.back = back || this._back; + } + + _back(timer, focused_timer) { + // Default back handler + + if (timer) { + switch_UI(new TimerView(timer)); + } else { + switch_UI(new TimerViewMenu(focused_timer)); + } + } + + start() { + // Display the timer menu + + let menu = { + '': { + title: "Timers", + back: () => { this.back(null, this.focused_timer); } + } + }; + this.timers.forEach((timer) => { + menu[timer.display_status() + ' ' + timer.display_name()] = + () => { this.back(timer, this.focused_timer); }; + }); + E.showMenu(menu); + } + + stop() { + // Shut down the UI and clean up listeners and handlers + + E.showMenu(); + } +} + + +class AppSettingsMenu { + // UI for displaying the app settings menu. + + constructor(timer, back) { + // `timer` is the last focused timer object (only used for default + // back handler described below). + // `back` is a function that activates the previous UI to + // return to when the menu is exited. If not specified, a default + // back handler is used that returns to the TimerViewMenu for the + // last-focused timer. + this.timer = timer; + this.back = back || this._back; + } + + _back() { + // Default back handler + switch_UI(new TimerViewMenu(this.timer)); + } + + start() { + // Display the app settings menu + + const menu = { + '': { + title: 'Settings', + back: () => { this.back(); } + }, + 'Button': { + value: tt.ACTIONS.indexOf(tt.SETTINGS.button_act), + min: 0, + max: tt.ACTIONS.length - 1, + format: v => tt.ACTION_NAMES[tt.ACTIONS[v]], + onchange: v => { + tt.SETTINGS.button_act = tt.ACTIONS[v]; + tt.set_settings_dirty(); + } + }, + 'Tap left': { + value: tt.ACTIONS.indexOf(tt.SETTINGS.left_tap_act), + min: 0, + max: tt.ACTIONS.length - 1, + format: v => tt.ACTION_NAMES[tt.ACTIONS[v]], + onchange: v => { + tt.SETTINGS.left_tap_act = tt.ACTIONS[v]; + tt.set_settings_dirty(); + } + }, + 'Tap right': { + value: tt.ACTIONS.indexOf(tt.SETTINGS.right_tap_act), + min: 0, + max: tt.ACTIONS.length - 1, + format: v => tt.ACTION_NAMES[tt.ACTIONS[v]], + onchange: v => { + tt.SETTINGS.right_tap_act = tt.ACTIONS[v]; + tt.set_settings_dirty(); + } + }, + 'Confirm reset': { + value: [true, 'auto', false].indexOf(tt.SETTINGS.confirm_reset), + format: v => ['Always', 'Auto', 'Never'][v], + min: 0, + max: 2, + onchange: v => { + tt.SETTINGS.confirm_reset = [true, 'auto', false][v]; + tt.set_settings_dirty(); + } + }, + 'Confirm delete': { + value: tt.SETTINGS.confirm_delete, // boolean + format: v => v ? 'Always' : 'Never', + onchange: v => { + tt.SETTINGS.confirm_delete = v; + tt.set_settings_dirty(); + } + }, + 'On alarm go to': { + value: tt.SETTINGS.alarm_return, // boolean + format: v => v ? 'Timer' : 'Clock', + onchange: v => { + tt.SETTINGS.alarm_return = v; + tt.set_settings_dirty(); + } + }, + 'Auto reset': { + value: tt.SETTINGS.auto_reset, // boolean + onchange: v => { + tt.SETTINGS.auto_reset = v; + tt.set_settings_dirty(); + } + } + }; + + E.showMenu(menu); + } + + stop() { + // Shut down the UI and clean up listeners and handlers + + E.showMenu(); + } +} + + +function switch_UI(new_UI) { + // Switch from one UI mode to another (after current call stack + // completes). The new UI instance is passed as a parameter. The old + // UI is stopped and cleaned up, and the new UI is started. + + setTimeout(() => { + if (CURRENT_UI) { + CURRENT_UI.stop(); + } + CURRENT_UI = new_UI; + CURRENT_UI.start(); + }, 0); +} + + +// Load and start up app // + +Bangle.loadWidgets(); +Bangle.drawWidgets(); + +var CURRENT_UI = null; + +tt.update_system_alarms(); + +update_status_widget(tt.TIMERS[0]); +switch_UI(new TimerView(tt.TIMERS[0])); diff --git a/apps/tevtimer/app.png b/apps/tevtimer/app.png new file mode 100644 index 000000000..39a12e448 Binary files /dev/null and b/apps/tevtimer/app.png differ diff --git a/apps/tevtimer/app.xcf b/apps/tevtimer/app.xcf new file mode 100644 index 000000000..450d2e08d Binary files /dev/null and b/apps/tevtimer/app.xcf differ diff --git a/apps/tevtimer/interface.html b/apps/tevtimer/interface.html new file mode 100644 index 000000000..625ca4e87 --- /dev/null +++ b/apps/tevtimer/interface.html @@ -0,0 +1,379 @@ + + + + + + + + + + +
Loading...
+ + + + diff --git a/apps/tevtimer/lib.js b/apps/tevtimer/lib.js new file mode 100644 index 000000000..5fcaba3d5 --- /dev/null +++ b/apps/tevtimer/lib.js @@ -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}; diff --git a/apps/tevtimer/metadata.json b/apps/tevtimer/metadata.json new file mode 100644 index 000000000..8ea7226f2 --- /dev/null +++ b/apps/tevtimer/metadata.json @@ -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"} + ] +} diff --git a/apps/tevtimer/screenshot.png b/apps/tevtimer/screenshot.png new file mode 100644 index 000000000..59bdbb26f Binary files /dev/null and b/apps/tevtimer/screenshot.png differ