diff --git a/apps/tevtimer/alarm.js b/apps/tevtimer/alarm.js new file mode 100644 index 000000000..677cf775b --- /dev/null +++ b/apps/tevtimer/alarm.js @@ -0,0 +1,66 @@ +const tt = require('triangletimer'); + +function showAlarm(alarm) { + const settings = require("sched").getSettings(); + const tri_timer = tt.TIMERS[alarm.data.idx]; + const message = tt.format_triangle(tri_timer) + '\n' + alarm.msg; + + Bangle.loadWidgets(); + Bangle.drawWidgets(); + + // buzzCount should really be called buzzRepeat, so subtract 1 + let buzzCount = tri_timer.buzz_count - 1; + + tt.update_system_alarms(); + + E.showPrompt(message, { + title: 'Triangle timer', + buttons: { "Goto": true, "OK": false } + }).then(function (go) { + buzzCount = 0; + + Bangle.emit("alarmDismiss", alarm); + + if (go) { + console.log('alarm ' + alarm.data.idx); + tt.set_last_viewed_timer(tri_timer); + load('triangletimer.app.js'); + } else { + load(); + } + }); + + function buzz() { + if (settings.unlockAtBuzz) { + Bangle.setLocked(false); + } + + const pattern = tri_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 = tri_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..2299fb6c4 --- /dev/null +++ b/apps/tevtimer/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("lEo4cC///A4IDC/Hgg/g/8f/H/g9JkmSAQORAgYCEAYcipPPnnzAQoOCpGSgN58+eAQoOComAgIdKHAMAgI7KxJlDBxNIgESBwO69euAQhWBoAOD69duoCEBwKSEDpAaCHZVIyCxFBw+AhIOEtu27YCDQYIONbwwOOHYwODg3AAgSnGBwfbAgUyBokgFIcbsEAgYOKJAOA7YOGiVJBwUN23YHYoOFgE2AQIIBBwZoGAAMf/4ACBxxoDBxSVHHYwOJgEJBxsOYgTgHBwz+HBw+QHZlJiQ8JggOCkQOJg4sBBwNAOIJ0CAQYODklIBxuJSQgEDJQckyK2DBwcJlMiBwWSRIIOFpRHB7YOCpGSYQtSBwoFBpL1BgMkygICBwgCBIggXDA==")) diff --git a/apps/tevtimer/app.js b/apps/tevtimer/app.js new file mode 100644 index 000000000..095f2c3c3 --- /dev/null +++ b/apps/tevtimer/app.js @@ -0,0 +1,545 @@ +const Layout = require('Layout'); + +const tt = require('triangletimer'); + +// UI // + +class TimerView { + constructor(tri_timer) { + this.tri_timer = tri_timer; + + this.layout = null; + this.listeners = {}; + this.timer_timeout = null; + } + + start() { + this._initLayout(); + this.layout.clear(); + this.render(); + tt.set_last_viewed_timer(this.tri_timer); + let render_status = () => { this.render(); }; + this.tri_timer.on('status', render_status); + this.tri_timer.on('auto-pause', tt.set_timers_dirty); + this.listeners.status = render_status; + + // Touch handler + function touchHandler(button, xy) { + for (var id of ['row1', 'row2', 'row3']) { + const elem = this.layout[id]; + if (!xy.type && + elem.x <= xy.x && xy.x < elem.x + elem.w && + elem.y <= xy.y && xy.y < elem.y + elem.h) { + Bangle.buzz(50, 0.5); + tt.SETTINGS.view_mode = (tt.SETTINGS.view_mode + 1) % 4; + tt.schedule_save_settings(); + setTimeout(this.render.bind(this), 0); + break; + } + } + } + this.listeners.touch = touchHandler.bind(this); + Bangle.on('touch', this.listeners.touch); + + // Physical button handler + this.listeners.button = setWatch( + this.start_stop_timer.bind(this), + BTN, + {edge: 'falling', debounce: 50, repeat: true} + ); + } + + stop() { + if (this.timer_timeout !== null) { + clearTimeout(this.timer_timeout); + this.timer_timeout = null; + } + this.tri_timer.removeListener('status', this.listeners.status); + Bangle.removeListener('touch', this.listeners.touch); + clearWatch(this.listeners.button); + Bangle.setUI(); + } + + _initLayout() { + const layout = new Layout( + { + type: 'v', + bgCol: g.theme.bg, + c: [ + { + type: 'txt', + id: 'row1', + label: '8888', + font: 'Vector:56x42', + fillx: 1, + }, + { + type: 'txt', + id: 'row2', + label: '8888', + font: 'Vector:56x56', + fillx: 1, + }, + { + type: 'txt', + id: 'row3', + label: '', + font: '12x20', + 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.tri_timer)); + } + } + ] + } + ] + } + ); + this.layout = layout; + } + + render(item) { + console.debug('render called: ' + item); + this.tri_timer.check_auto_pause(); + + if (!item) { + this.layout.update(); + } + + if (!item || item == 'timer') { + + let timer_as_linear = this.tri_timer.get(); + if (timer_as_linear < 0) { + // Handle countdown timer expiration + timer_as_linear = 0; + setTimeout(() => { this.render('status'); }, 0); + } + const timer_as_tri = tt.as_triangle( + timer_as_linear, this.tri_timer.increment); + + var label1, label2, font1, font2; + if (tt.SETTINGS.view_mode == 0) { + label1 = timer_as_tri[0]; + label2 = Math.ceil(timer_as_tri[1]); + font1 = 'Vector:56x42'; + font2 = 'Vector:56x56'; + } else if (tt.SETTINGS.view_mode == 1) { + label1 = timer_as_tri[0]; + label2 = Math.ceil(timer_as_tri[0] - timer_as_tri[1]); + font1 = 'Vector:56x42'; + font2 = 'Vector:56x56'; + } else if (tt.SETTINGS.view_mode == 2) { + label1 = tt.format_triangle(this.tri_timer); + let ttna = this.tri_timer.time_to_next_event(); + if (ttna !== null) { + label2 = tt.format_duration(ttna, true); + } else { + label2 = '--:--:--'; + } + font1 = 'Vector:30x42'; + font2 = 'Vector:34x56'; + } else if (tt.SETTINGS.view_mode == 3) { + label1 = timer_as_tri[0]; + let ttna = this.tri_timer.time_to_next_event(); + if (ttna !== null) { + label2 = tt.format_duration(ttna, false); + } else { + label2 = '--:--'; + } + font1 = 'Vector:56x42'; + font2 = 'Vector:48x56'; + } + + if (label1 !== this.layout.row1.label) { + this.layout.row1.label = label1; + this.layout.row1.font = font1; + this.layout.clear(this.layout.row1); + this.layout.render(this.layout.row1); + } + + if (label2 !== this.layout.row2.label) { + this.layout.row2.label = label2; + this.layout.row2.font = font2; + this.layout.clear(this.layout.row2); + this.layout.render(this.layout.row2); + } + + } + + if (!item || item == 'status') { + this.layout.start_btn.label = + this.tri_timer.is_running() ? 'Pause' : 'Start'; + this.layout.render(this.layout.buttons); + + this.layout.row3.label = + this.tri_timer.display_status() + + ' ' + this.tri_timer.provisional_name(); + this.layout.clear(this.layout.row3); + this.layout.render(this.layout.row3); + } + + if (this.tri_timer.is_running() && this.tri_timer.get() > 0) { + if (this.timer_timeout) { + clearTimeout(this.timer_timeout); + this.timer_timeout = null; + } + + // Calculate approximate time next display update is needed. + // Usual case: update when numbers change once per second. + let next_tick = this.tri_timer.get() % 1; + if (this.tri_timer.rate > 0) { + next_tick = 1 - next_tick; + } + // Convert next_tick from seconds to milliseconds and add + // compensating factor of 50ms due to timeouts apparently + // sometimes triggering too early. + next_tick = next_tick / Math.abs(this.tri_timer.rate) + 50; + + // For slow-update view mode, only update about every 60 + // seconds instead of every second + if (tt.SETTINGS.view_mode == 3) { + console.debug(this.tri_timer.time_to_next_event()); + next_tick = this.tri_timer.time_to_next_event() % 60000; + } + + console.debug('Next render update scheduled in ' + next_tick); + this.timer_timeout = setTimeout( + () => { this.timer_timeout = null; this.render('timer'); }, + next_tick + ); + } + } + + start_stop_timer() { + if (this.tri_timer.is_running()) { + this.tri_timer.pause(); + } else { + this.tri_timer.start(); + } + tt.set_timers_dirty(); + } +} + + +class TimerViewMenu { + constructor(tri_timer) { + this.tri_timer = tri_timer; + } + + start() { + this.top_menu(); + } + + stop() { + E.showMenu(); + } + + back() { + switch_UI(new TimerView(this.tri_timer)); + } + + top_menu() { + const top_menu = { + '': { + title: this.tri_timer.display_name(), + back: this.back.bind(this) + }, + 'Reset': () => { E.showMenu(reset_menu); }, + 'Timers': () => { + switch_UI(new TimerMenu(tt.TIMERS, this.tri_timer)); + }, + 'Edit': this.edit_menu.bind(this), + 'Add': () => { + tt.set_timers_dirty(); + const new_timer = tt.add_tri_timer(tt.TIMERS, this.tri_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.tri_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_tri_timer(tt.TIMERS, this.tri_timer))); + }, + 'Cancel': () => { E.showMenu(top_menu); }, + }; + + E.showMenu(top_menu); + } + + edit_menu() { + const edit_menu = { + '': { + title: 'Edit: ' + this.tri_timer.display_name(), + back: () => { this.top_menu(); }, + }, + 'Direction': { + value: this.tri_timer.rate >= 0, + format: v => (v ? 'Up' : 'Down'), + onchange: v => { + this.tri_timer.rate = -this.tri_timer.rate; + tt.set_timers_dirty(); + } + }, + 'Start (Tri)': this.edit_start_tri_menu.bind(this), + 'Start (HMS)': this.edit_start_hms_menu.bind(this), + 'Increment': { + value: this.tri_timer.increment, + min: 1, + max: 9999, + step: 1, + wrap: true, + onchange: v => { + this.tri_timer.increment = v; + tt.set_timers_dirty(); + }, + }, + 'Events': this.edit_events_menu.bind(this), + }; + + E.showMenu(edit_menu); + } + + edit_start_tri_menu() { + let origin_tri = tt.as_triangle( + this.tri_timer.origin, this.tri_timer.increment); + + const edit_start_tri_menu = { + '': { + title: 'Start (Tri)', + back: this.edit_menu.bind(this), + }, + 'Outer': { + value: origin_tri[0], + min: 0, + max: Math.floor(9999 / this.tri_timer.increment) + * this.tri_timer.increment, + step: this.tri_timer.increment, + wrap: true, + noList: true, + onchange: v => { + origin_tri[0] = v; + edit_start_tri_menu.Inner.max = origin_tri[0]; + origin_tri[1] = (this.tri_timer.rate >= 0) ? + 1 : origin_tri[0]; + edit_start_tri_menu.Inner.value = origin_tri[1]; + this.tri_timer.origin = tt.as_linear( + origin_tri, this.tri_timer.increment + ); + tt.set_timers_dirty(); + } + }, + 'Inner': { + value: origin_tri[1], + min: 0, + max: origin_tri[0], + step: 1, + wrap: true, + noList: true, + onchange: v => { + origin_tri[1] = v; + this.tri_timer.origin = tt.as_linear( + origin_tri, this.tri_timer.increment + ); + tt.set_timers_dirty(); + } + }, + }; + + E.showMenu(edit_start_tri_menu); + } + + edit_start_hms_menu() { + let origin_hms = { + h: Math.floor(this.tri_timer.origin / 3600), + m: Math.floor(this.tri_timer.origin / 60) % 60, + s: Math.floor(this.tri_timer.origin % 60), + }; + + const update_origin = () => { + this.tri_timer.origin = origin_hms.h * 3600 + + origin_hms.m * 60 + + origin_hms.s; + }; + + const edit_start_hms_menu = { + '': { + title: 'Start (HMS)', + back: this.edit_menu.bind(this), + }, + 'Hours': { + value: origin_hms.h, + min: 0, + max: 9999, + wrap: true, + onchange: v => { + origin_hms.h = v; + update_origin(); + tt.set_timers_dirty(); + } + }, + 'Minutes': { + value: origin_hms.m, + min: 0, + max: 59, + wrap: true, + onchange: v => { + origin_hms.m = v; + update_origin(); + tt.set_timers_dirty(); + } + }, + 'Seconds': { + value: origin_hms.s, + min: 0, + max: 59, + wrap: true, + onchange: v => { + origin_hms.s = v; + update_origin(); + tt.set_timers_dirty(); + } + }, + }; + + E.showMenu(edit_start_hms_menu); + } + + edit_events_menu() { + const events_menu = { + '': { + title: 'Events', + back: () => { this.edit_menu(); } + }, + 'Outer alarm': { + value: this.tri_timer.outer_alarm, + format: v => (v ? 'On' : 'Off'), + onchange: v => { + this.tri_timer.outer_alarm = v; + tt.set_timers_dirty(); + }, + }, + 'Outer action': { + value: tt.ACTIONS.indexOf(this.tri_timer.outer_action), + min: 0, + max: tt.ACTIONS.length - 1, + format: v => tt.ACTIONS[v], + onchange: v => { + this.tri_timer.outer_action = tt.ACTIONS[v]; + tt.set_timers_dirty(); + }, + }, + 'End alarm': { + value: this.tri_timer.end_alarm, + format: v => (v ? 'On' : 'Off'), + onchange: v => { + this.tri_timer.end_alarm = v; + tt.set_timers_dirty(); + }, + }, + 'Vibrate pattern': require("buzz_menu").pattern( + this.tri_timer.vibrate_pattern, + v => this.tri_timer.vibrate_pattern = v), + 'Buzz count': { + value: this.tri_timer.buzz_count, + min: 0, + max: 15, + step: 1, + wrap: true, + format: v => v === 0 ? "Forever" : v, + onchange: v => { + this.tri_timer.buzz_count = v; + tt.set_timers_dirty(); + }, + }, + }; + + E.showMenu(events_menu); + } +} + + +class TimerMenu { + constructor(tri_timers, focused_timer) { + this.tri_timers = tri_timers; + this.focused_timer = focused_timer; + } + + start() { + this.top_menu(); + } + + stop() { + E.showMenu(); + } + + back() { + switch_UI(new TimerViewMenu(this.focused_timer)); + } + + top_menu() { + let menu = { + '': { + title: "Timers", + back: this.back.bind(this) + } + }; + this.tri_timers.forEach((tri_timer) => { + menu[tri_timer.display_status() + ' ' + tri_timer.display_name()] = + () => { switch_UI(new TimerView(tri_timer)); }; + }); + E.showMenu(menu); + } +} + + +function switch_UI(new_UI) { + 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(); + +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..507eb696b 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..2db2c9630 Binary files /dev/null and b/apps/tevtimer/app.xcf differ diff --git a/apps/tevtimer/lib.js b/apps/tevtimer/lib.js new file mode 100644 index 000000000..403fc86db --- /dev/null +++ b/apps/tevtimer/lib.js @@ -0,0 +1,462 @@ +const Storage = require('Storage'); +const Sched = require('sched'); +const Time_utils = require('time_utils'); + + +// Data models // + +class PrimitiveTimer { + constructor(origin, is_running, rate, name) { + this.origin = origin || 0; + // default rate +1 unit per 1000 ms, countup + this.rate = rate || 0.001; + this.name = name || ''; + + this._start_time = Date.now(); + this._pause_time = is_running ? null : this._start_time; + } + + display_name() { + return this.name ? this.name : this.provisional_name(); + } + + provisional_name() { + return (this.rate >= 0 ? 'U' : 'D') + + ' ' + + Time_utils.formatDuration(this.origin / this.rate); + } + + display_status() { + 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 !this._pause_time; + } + + start() { + if (!this.is_running()) { + this._start_time += Date.now() - this._pause_time; + this._pause_time = null; + } + } + + pause() { + if (this.is_running()) { + this._pause_time = Date.now(); + } + } + + reset() { + this.set(this.origin); + } + + get() { + 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) { + const now = Date.now(); + this._start_time = (now - new_value / this.rate) + + (this.origin / this.rate); + if (!this.is_running()) { + this._pause_time = now; + } + } + + dump() { + return { + cls: 'PrimitiveTimer', + version: 0, + origin: this.origin, + rate: this.rate, + name: this.name, + start_time: this._start_time, + pause_time: this._pause_time + }; + } + + static load(data) { + 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); + loaded._start_time = data.start_time; + loaded._pause_time = data.pause_time; + return loaded; + } +} + + +class TriangleTimer extends PrimitiveTimer { + constructor(origin, is_running, rate, name, increment) { + super(origin, is_running, rate, name); + this.increment = increment || 1; + + this.end_alarm = false; + this.outer_alarm = false; + this.outer_action = 'Cont'; + this.pause_checkpoint = null; + this.vibrate_pattern = null; + this.buzz_count = 4; + } + + provisional_name() { + const origin_as_tri = as_triangle( + this.origin, + this.increment + ); + return (this.rate >= 0 ? 'U' : 'D') + + ' ' + + origin_as_tri[0] + '/' + origin_as_tri[1] + + ' x' + this.increment; + } + + start() { + super.start(); + this.emit('status'); + } + + pause() { + super.pause(); + this.emit('status'); + } + + check_auto_pause() { + const current_time = super.get(); + + if (this.is_running() && + this.outer_action == 'Pause') { + if (this.pause_checkpoint === null) { + this.pause_checkpoint = current_time + + this.time_to_next_outer_event() * this.rate; + console.debug('timer auto-pause setup: ' + this.pause_checkpoint); + } else if ( + (this.rate >= 0 && current_time >= this.pause_checkpoint) + || (this.rate < 0 && current_time <= this.pause_checkpoint) + ) { + console.debug('timer auto-pause triggered'); + this.pause(); + this.set(this.pause_checkpoint); + this.pause_checkpoint = null; + this.emit('auto-pause'); + } + } else { + this.pause_checkpoint = null; + } + } + + reset() { + this.pause_checkpoint = null; + return super.reset(); + } + + get() { + return super.get(); + } + + set(new_value) { + return super.set(new_value); + } + + time_to_next_alarm() { + if (!this.is_running()) + return null; + + if (this.outer_alarm) { + return this.time_to_next_outer_event(); + } + + if (this.end_alarm) { + return this.time_to_end_event(); + } + + return null; + } + + time_to_next_event() { + let next = null; + + if (this.outer_alarm || this.outer_action !== 'Cont') { + next = this.time_to_next_outer_event(); + } + + if (next === null) { + next = this.time_to_end_event(); + } + + return next + } + + time_to_next_outer_event() { + const as_tri = as_triangle(super.get(), this.increment); + let inner_left = this.rate > 0 ? as_tri[0] - as_tri[1] : as_tri[1]; + // Avoid getting stuck if we're paused precisely on the event time + if (!inner_left) { + inner_left = as_tri[0] + Math.sign(this.rate) * this.increment; + } + return Math.max(0, inner_left / Math.abs(this.rate)); + } + + time_to_end_event() { + if (this.rate <= 0 && this.get() > 0) { + return this.get() / Math.abs(this.rate); + } + return null; + } + + dump() { + let data = super.dump(); + data.cls = 'TriangleTimer'; + data.increment = this.increment; + data.end_alarm = this.end_alarm; + data.outer_alarm = this.outer_alarm; + data.outer_action = this.outer_action; + data.pause_checkpoint = this.pause_checkpoint; + data.vibrate_pattern = this.vibrate_pattern; + data.buzz_count = this.buzz_count; + return data; + } + + static load(data) { + if (!(data.cls == 'TriangleTimer' && data.version == 0)) { + console.error('Incompatible data type for loading TriangleTimer state'); + } + let loaded = new this( + data.origin, false, data.rate, data.name, data.increment); + loaded._start_time = data.start_time; + loaded._pause_time = data.pause_time; + loaded.end_alarm = data.end_alarm; + loaded.outer_alarm = data.outer_alarm; + loaded.outer_action = data.outer_action; + loaded.pause_checkpoint = data.pause_checkpoint; + loaded.vibrate_pattern = data.vibrate_pattern; + if (data.buzz_count !== undefined) { + loaded.buzz_count = data.buzz_count; + } + return loaded; + } +} + + +function fixed_ceil(value) { + // JavaScript sucks balls + return Math.ceil(Math.round(value * 1e10) / 1e10); +} + + +function as_triangle(linear_time, increment) { + if (increment === undefined) increment = 1; + linear_time = linear_time / increment; + const outer = fixed_ceil((Math.sqrt(linear_time * 8 + 1) - 1) / 2); + const inner = outer - (outer * (outer + 1) / 2 - linear_time); + return [outer * increment, inner * increment]; +} + +function as_linear(triangle_time, increment) { + if (increment === undefined) increment = 1; + const outer = triangle_time[0], inner = triangle_time[1]; + return (outer + (outer - 1) % increment + 1) + * fixed_ceil(outer / increment) / 2 + - outer + inner; +} + + +function format_triangle(tri_timer) { + const tri = as_triangle(tri_timer.get(), tri_timer.increment); + return tri[0] + '/' + Math.ceil(tri[1]); +} + + +function format_duration(msec, have_seconds) { + const time = Time_utils.decodeTime(msec); + time.h += time.d * 24; + let str = time.h + ":" + ("0" + time.m).substr(-2); + if (have_seconds) { + str += ":" + ("0" + time.s).substr(-2); + } + return str; +} + + +// Persistent state // + +const TIMERS_FILENAME = 'triangletimer.timers.json'; +const SETTINGS_FILENAME = 'triangletimer.json'; + +const SCHEDULED_SAVE_TIMEOUT = 15000; + +var SAVE_TIMERS_TIMEOUT = null; +var SAVE_SETTINGS_TIMEOUT = null; + + +function load_timers() { + console.log('loading timers'); + let timers = Storage.readJSON(TIMERS_FILENAME, true) || []; + if (timers.length) { + // Deserealize timer objects + timers = timers.map(t => TriangleTimer.load(t)); + } else { + timers = [new TriangleTimer()]; + } + return timers; +} + +function save_timers() { + 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() { + 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() { + console.log('saving settings'); + if (!Storage.writeJSON(SETTINGS_FILENAME, SETTINGS)) { + E.showAlert('Trouble saving settings'); + } +} + +function schedule_save_settings() { + 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'); + } +} + +const SETTINGS = Object.assign({ + 'view_mode': 0, +}, Storage.readJSON(SETTINGS_FILENAME, true) || {}); + +var TIMERS = load_timers(); + +const ACTIONS = [ + 'Cont', + 'Pause', +]; + + +// Persistent data convenience functions + +function delete_tri_timer(timers, tri_timer) { + const idx = timers.indexOf(tri_timer); + if (idx !== -1) { + timers.splice(idx, 1); + } else { + console.warn('delete_tri_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_tri_timer(timers, tri_timer) { + // Create a copy of current timer object + const new_timer = TriangleTimer.load(tri_timer.dump()); + timers.unshift(new_timer); + return new_timer; +} + +function set_last_viewed_timer(tri_timer) { + const idx = TIMERS.indexOf(tri_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 tri_timer to top of list + TIMERS.splice(idx, 1); + TIMERS.unshift(tri_timer); + set_timers_dirty(); + } +} + +function set_timers_dirty() { + setTimeout(update_system_alarms, 500); + schedule_save_timers(); +} + +function set_settings_dirty() { + schedule_save_settings(); +} + + +// Alarm handling // + +function delete_system_alarms() { + var alarms = Sched.getAlarms().filter(a => a.appid == 'triangletimer'); + for (let alarm of alarms) { + console.debug('delete sched alarm ' + alarm.id); + Sched.setAlarm(alarm.id, undefined); + } + Sched.reload(); +} + +function set_system_alarms() { + for (let idx = 0; idx < TIMERS.length; idx++) { + let timer = TIMERS[idx]; + timer.check_auto_pause(); + let time_to_next_alarm = timer.time_to_next_alarm(); + if (time_to_next_alarm !== null) { + console.debug('set sched alarm ' + idx + ' (' + time_to_next_alarm/1000 + ')'); + Sched.setAlarm(idx.toString(), { + appid: 'triangletimer', + timer: time_to_next_alarm, + msg: timer.display_name(), + js: "load('triangletimer.alarm.js');", + data: { idx: idx }, + }); + } + } + Sched.reload(); +} + +function update_system_alarms() { + delete_system_alarms(); + set_system_alarms(); +} + + +E.on('kill', () => { save_timers(); }); +E.on('kill', () => { save_settings(); }); + + +exports = {TIMERS, SETTINGS, ACTIONS, + load_timers, save_timers, schedule_save_timers, save_settings, schedule_save_settings, + PrimitiveTimer, TriangleTimer, + as_triangle, as_linear, format_triangle, format_duration, + delete_tri_timer, add_tri_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..81cfed958 --- /dev/null +++ b/apps/tevtimer/metadata.json @@ -0,0 +1,21 @@ +{ + "id": "triangletimer", + "name": "Triangle timer", + "shortName":"Triangle timer", + "icon": "app.png", + "version": "0.01", + "description": "Timers with incrementally increasing or decreasing periods", + "tags": "timer", + "supports": ["BANGLEJS2"], + "dependencies": {"scheduler": "type"}, + "storage": [ + {"name": "triangletimer.app.js", "url": "app.js"}, + {"name": "triangletimer.alarm.js", "url": "alarm.js"}, + {"name": "triangletimer", "url": "lib.js"}, + {"name": "triangletimer.img", "url": "app-icon.js", "evaluate": true} + ], + "data": [ + {"name": "triangletimer.json"}, + {"name": "triangletimer.timers.json"} + ] +}