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', width: width, draw: widget_draw, }; } WIDGETS.tevtimer.width = width; Bangle.drawWidgets(); } 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.start_stop_timer.bind(this), BTN, {edge: 'falling', debounce: 50, repeat: true} ); 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('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'); } } class TimerFormatView { // UI for selecting the display format of a timer. The // PrimitiveTimer object is passed to the constructor as a // parameter. constructor(timer) { this.timer = timer; 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; } } 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 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 && xy.x <= this.layout[elem].x + this.layout[elem].w && 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.ok.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(); switch_UI(new TimerView(this.timer)); } cancel() { // Return to TimerViewMenu without saving changes switch_UI(new TimerViewMenu(this.timer)); } } class TimerViewMenu { // UI for displaying the timer menu. The PrimitiveTimer object is // passed to the constructor as a parameter. constructor(timer) { this.timer = timer; } start() { // Display and activate the top menu of the timer view menu. this.top_menu(); } stop() { // Shut down the UI and clean up listeners and handlers E.showMenu(); } back() { // Return to the timer view // (i.e. the timer that was previously displayed) switch_UI(new TimerView(this.timer)); } top_menu() { // Display the top-level menu for the timer const top_menu = { '': { title: this.timer.display_name(), back: this.back.bind(this) }, 'Reset': () => { E.showMenu(reset_menu); }, 'Timers': () => { switch_UI(new TimerMenu(tt.TIMERS, this.timer)); }, 'Edit': this.edit_menu.bind(this), 'Format': () => { switch_UI(new TimerFormatView(this.timer)); }, 'Add': () => { tt.set_timers_dirty(); const new_timer = tt.add_timer(tt.TIMERS, this.timer); const timer_view_menu = new TimerViewMenu(new_timer); timer_view_menu.edit_menu(); }, 'Delete': () => { E.showMenu(delete_menu); }, }; if (tt.TIMERS.length <= 1) { // Prevent user deleting last timer delete top_menu.Delete; } const reset_menu = { '': { title: 'Confirm reset', back: () => { E.showMenu(top_menu); } }, 'Reset': () => { this.timer.reset(); tt.set_timers_dirty(); this.back(); }, 'Cancel': () => { E.showMenu(top_menu); }, }; const delete_menu = { '': { title: 'Confirm delete', back: () => { E.showMenu(top_menu); } }, 'Delete': () => { tt.set_timers_dirty(); switch_UI(new TimerView(tt.delete_timer(tt.TIMERS, this.timer))); }, 'Cancel': () => { E.showMenu(top_menu); }, }; E.showMenu(top_menu); } edit_menu() { // Display the edit menu for the timer. This can be called in // place of `start` to jump directly to the edit menu. let keyboard = null; try { keyboard = require("textinput"); } catch (e) {} const edit_menu = { '': { title: 'Edit: ' + this.timer.display_name(), back: () => { this.top_menu(); }, }, 'Name': { value: this.timer.name, onchange: () => { setTimeout(() => { keyboard.input({text:this.timer.name}).then(text => { this.timer.name = text; tt.set_timers_dirty(); setTimeout(() => { this.edit_menu(); }, 0); }); }, 0); } }, 'Start': this.edit_start.bind(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) { delete edit_menu.Name; } E.showMenu(edit_menu); } edit_start() { // Display the edit > start menu for the timer // (i.e. the timer's starting value) 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.edit_menu.bind(this), onchange: (h, m, s) => { this.timer.origin = h * 3600 + m * 60 + s; tt.set_timers_dirty(); } }); } } class TimerMenu { // UI for displaying the list of timers. The list of // PrimitiveTimer objects is passed to the constructor as a // parameter. The currently focused timer is passed as the // second parameter. constructor(timers, focused_timer) { this.timers = timers; this.focused_timer = focused_timer; } start() { // Display the top timer menu this.top_menu(); } stop() { // Shut down the UI and clean up listeners and handlers E.showMenu(); } back() { // Return to the timer's menu switch_UI(new TimerViewMenu(this.focused_timer)); } top_menu() { // Display the top-level menu for the timer list let menu = { '': { title: "Timers", back: this.back.bind(this) } }; this.timers.forEach((timer) => { menu[timer.display_status() + ' ' + timer.display_name()] = () => { switch_UI(new TimerView(timer)); }; }); E.showMenu(menu); } } function switch_UI(new_UI) { // Switch from one UI to another. The new UI instance is passed as a // parameter. The old UI is stopped and cleaned up, and the new // UI is started. if (CURRENT_UI) { CURRENT_UI.stop(); } CURRENT_UI = new_UI; CURRENT_UI.start(); } // 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]));