diff --git a/apps/timestamplog/ChangeLog b/apps/timestamplog/ChangeLog new file mode 100644 index 000000000..ec66c5568 --- /dev/null +++ b/apps/timestamplog/ChangeLog @@ -0,0 +1 @@ +0.01: Initial version diff --git a/apps/timestamplog/app.js b/apps/timestamplog/app.js index 87f227f46..b4e5a0ee4 100644 --- a/apps/timestamplog/app.js +++ b/apps/timestamplog/app.js @@ -1,10 +1,9 @@ -Layout = require('Layout'); -locale = require('locale'); -storage = require('Storage'); +const Layout = require('Layout'); +const locale = require('locale'); +const storage = require('Storage'); + +const tsl = require('timestamplog'); -// Storage filenames -const LOG_FILENAME = 'timestamplog.json'; -const SETTINGS_FILENAME = 'timestamplog.settings.json'; // Min number of pixels of movement to recognize a touchscreen drag/swipe const DRAG_THRESHOLD = 30; @@ -13,35 +12,6 @@ const DRAG_THRESHOLD = 30; const SCROLL_BAR_WIDTH = 12; -// Settings - -const SETTINGS = Object.assign({ - logFont: '12x20', - logFontHSize: 1, - logFontVSize: 1, - maxLogLength: 30, - rotateLog: false, - buttonAction: 'Log time', -}, storage.readJSON(SETTINGS_FILENAME, true) || {}); - -const SETTINGS_BUTTON_ACTION = [ - 'Log time', - 'Show menu', - 'Quit app', - 'Do nothing', -]; - -function saveSettings() { - if (!storage.writeJSON(SETTINGS_FILENAME, SETTINGS)) { - E.showAlert('Trouble saving settings'); - } -} - -function fontSpec(name, hsize, vsize) { - return name + ':' + hsize + 'x' + vsize; -} - - // Fetch a stringified image function getIcon(id) { if (id == 'add') { @@ -88,120 +58,13 @@ function getIcon(id) { } -//// Data models ////////////////////////////////// - -// High-level timestamp log object that provides an interface to the -// UI for managing log entries and automatically loading/saving -// changes to flash storage. -class StampLog { - constructor(filename, maxLength) { - // Name of file to save log to - this.filename = filename; - // Maximum entries for log before old entries are overwritten with - // newer ones - this.maxLength = maxLength; - - // `true` when we have changes that need to be saved - this.isDirty = false; - // Wait at most this many msec upon first data change before - // saving (this is to avoid excessive writes to flash if several - // changes happen quickly; we wait a little bit so they can be - // rolled into a single write) - this.saveTimeout = 30000; - // setTimeout ID for scheduled save job - this.saveId = null; - // Underlying raw log data object. Outside this class it's - // recommended to use only the class methods to change it rather - // than modifying the object directly to ensure that changes are - // recognized and saved to storage. - this.log = this.load(); - } - - // Return the version of the log data that is currently in storage - load() { - let log = storage.readJSON(this.filename, true); - if (!log) log = []; - // Convert stringified datetimes back into Date objects - for (let logEntry of log) { - logEntry.stamp = new Date(logEntry.stamp); - } - return log; - } - - // Write current log data to storage if anything needs to be saved - save() { - // Cancel any pending scheduled calls to save() - if (this.saveId) { - clearTimeout(this.saveId); - this.saveId = null; - } - - if (this.isDirty) { - let logToSave = []; - for (let logEntry of this.log) { - // Serialize each Date object into an ISO string before saving - let newEntry = Object.assign({}, logEntry); - newEntry.stamp = logEntry.stamp.toISOString(); - logToSave.push(newEntry); - } - - if (storage.writeJSON(this.filename, logToSave)) { - console.log('stamplog: save to storage completed'); - this.isDirty = false; - } else { - console.log('stamplog: save to storage FAILED'); - this.emit('saveError'); - } - } else { - console.log('stamplog: skipping save to storage because no changes made'); - } - } - - // Mark log as needing to be (re)written to storage - setDirty() { - this.isDirty = true; - if (!this.saveId) { - this.saveId = setTimeout(this.save.bind(this), this.saveTimeout); - } - } - - // Add a timestamp for the current time to the end of the log - addEntry() { - // If log full, purge an old entry to make room for new one - if (this.maxLength) { - while (this.log.length + 1 > this.maxLength) { - this.log.shift(); - } - } - // Add new entry - this.log.push({ - stamp: new Date() - }); - this.setDirty(); - } - - // Delete the log objects given in the array `entries` from the log - deleteEntries(entries) { - this.log = this.log.filter(entry => !entries.includes(entry)); - this.setDirty(); - } - - // Does the log currently contain the maximum possible number of entries? - isFull() { - return this.log.length >= this.maxLength; - } -} - - -//// UI /////////////////////////////////////////// - // UI layout render callback for log entries function renderLogItem(elem) { if (elem.item) { g.setColor(g.theme.bg) .fillRect(elem.x, elem.y, elem.x + elem.w - 1, elem.y + elem.h - 1) - .setFont(fontSpec(SETTINGS.logFont, - SETTINGS.logFontHSize, SETTINGS.logFontVSize)) + .setFont(tsl.fontSpec(tsl.SETTINGS.logFont, + tsl.SETTINGS.logFontHSize, tsl.SETTINGS.logFontVSize)) .setFontAlign(-1, -1) .setColor(g.theme.fg) .drawLine(elem.x, elem.y, elem.x + elem.w - 1, elem.y) @@ -254,9 +117,7 @@ function renderScrollBar(elem, scroll) { // Main app screen interface, launched by calling start() class MainScreen { - constructor(stampLog) { - this.stampLog = stampLog; - + constructor() { // Values set up by start() this.itemsPerPage = null; this.scrollPos = null; @@ -320,7 +181,7 @@ class MainScreen { {type: 'btn', font: '6x8:2', fillx: 1, label: '+ XX:XX', id: 'addBtn', cb: this.addTimestamp.bind(this)}, {type: 'btn', font: '6x8:2', label: getIcon('menu'), id: 'menuBtn', - cb: launchSettingsMenu}, + cb: () => launchSettingsMenu(returnFromSettings)}, ], }, ], @@ -330,8 +191,8 @@ class MainScreen { // Calculate how many log items per page we have space to display layout.update(); let availableHeight = layout.placeholder.h; - g.setFont(fontSpec(SETTINGS.logFont, - SETTINGS.logFontHSize, SETTINGS.logFontVSize)); + g.setFont(tsl.fontSpec(tsl.SETTINGS.logFont, + tsl.SETTINGS.logFontHSize, tsl.SETTINGS.logFontVSize)); let logItemHeight = g.getFontHeight() * 2; this.itemsPerPage = Math.max(1, Math.floor(availableHeight / logItemHeight)); @@ -357,7 +218,7 @@ class MainScreen { let logIdx = this.scrollPos - this.itemsPerPage; for (let elem of layLogItems.c) { logIdx++; - elem.item = this.stampLog.log[logIdx]; + elem.item = stampLog.log[logIdx]; elem.itemIdx = logIdx; } this.layout.render(layLogItems); @@ -367,7 +228,7 @@ class MainScreen { if (!item || item == 'buttons') { let addBtn = this.layout.addBtn; - if (!SETTINGS.rotateLog && this.stampLog.isFull()) { + if (!tsl.SETTINGS.rotateLog && stampLog.isFull()) { // Dimmed appearance for unselectable button addBtn.btnFaceCol = g.blendColor(g.theme.bg2, g.theme.bg, 0.5); addBtn.btnBorderCol = g.blendColor(g.theme.fg2, g.theme.bg, 0.5); @@ -433,7 +294,7 @@ class MainScreen { logUIObj.x <= xy.x && xy.x < logUIObj.x + logUIObj.w && logUIObj.y <= xy.y && xy.y < logUIObj.y + logUIObj.h && logUIObj.item) { - switchUI(new LogEntryScreen(this.stampLog, logUIObj.itemIdx)); + switchUI(new LogEntryScreen(stampLog, logUIObj.itemIdx)); break; } } @@ -443,14 +304,14 @@ class MainScreen { Bangle.on('touch', this.listeners.touch); function buttonHandler() { - let act = SETTINGS.buttonAction; + let act = tsl.SETTINGS.buttonAction; if (act == 'Log time') { if (currentUI != mainUI) { switchUI(mainUI); } mainUI.addTimestamp(); - } else if (act == 'Show menu') { - launchSettingsMenu(); + } else if (act == 'Open settings') { + launchSettingsMenu(returnFromSettings); } else if (act == 'Quit app') { Bangle.showClock(); } @@ -462,8 +323,8 @@ class MainScreen { // Add current timestamp to log if possible and update UI display addTimestamp() { - if (SETTINGS.rotateLog || !this.stampLog.isFull()) { - this.stampLog.addEntry(); + if (tsl.SETTINGS.rotateLog || !stampLog.isFull()) { + stampLog.addEntry(); this.scroll('b'); this.render('buttons'); } @@ -473,8 +334,8 @@ class MainScreen { scrollInfo() { return { pos: this.scrollPos, - min: (this.stampLog.log.length - 1) % this.itemsPerPage, - max: this.stampLog.log.length - 1, + min: (stampLog.log.length - 1) % this.itemsPerPage, + max: stampLog.log.length - 1, itemsPerPage: this.itemsPerPage }; } @@ -524,12 +385,12 @@ class MainScreen { class LogEntryScreen { constructor(stampLog, logIdx) { - this.stampLog = stampLog; + stampLog = stampLog; this.logIdx = logIdx; this.logItem = stampLog.log[logIdx]; - this.defaultFont = fontSpec( - SETTINGS.logFont, SETTINGS.logFontHSize, SETTINGS.logFontVSize); + this.defaultFont = tsl.fontSpec( + tsl.SETTINGS.logFont, tsl.SETTINGS.logFontHSize, tsl.SETTINGS.logFontVSize); } start() { @@ -577,7 +438,7 @@ class LogEntryScreen { } refresh() { - this.logItem = this.stampLog.log[this.logIdx]; + this.logItem = stampLog.log[this.logIdx]; this.layout.date.label = locale.date(this.logItem.stamp, 1); this.layout.time.label = locale.time(this.logItem.stamp).trim(); this.layout.update(); @@ -585,22 +446,22 @@ class LogEntryScreen { } prevLogItem() { - this.logIdx = this.logIdx ? this.logIdx-1 : this.stampLog.log.length-1; + this.logIdx = this.logIdx ? this.logIdx-1 : stampLog.log.length-1; this.refresh(); } nextLogItem() { - this.logIdx = this.logIdx == this.stampLog.log.length-1 ? 0 : this.logIdx+1; + this.logIdx = this.logIdx == stampLog.log.length-1 ? 0 : this.logIdx+1; this.refresh(); } delLogItem() { - this.stampLog.deleteEntries([this.logItem]); - if (!this.stampLog.log.length) { + stampLog.deleteEntries([this.logItem]); + if (!stampLog.log.length) { this.back(); return; - } else if (this.logIdx > this.stampLog.log.length - 1) { - this.logIdx = this.stampLog.log.length - 1; + } else if (this.logIdx > stampLog.log.length - 1) { + this.logIdx = stampLog.log.length - 1; } // Create a brief “blink” on the screen to provide user feedback @@ -612,104 +473,10 @@ class LogEntryScreen { } -function launchSettingsMenu() { - const fonts = g.getFonts(); - - function topMenu() { - E.showMenu({ - '': { - title: 'Stamplog', - back: endMenu, - }, - 'Log': logMenu, - 'Appearance': appearanceMenu, - 'Button': { - value: SETTINGS_BUTTON_ACTION.indexOf(SETTINGS.buttonAction), - min: 0, max: SETTINGS_BUTTON_ACTION.length - 1, - format: v => SETTINGS_BUTTON_ACTION[v], - onchange: v => { - SETTINGS.buttonAction = SETTINGS_BUTTON_ACTION[v]; - }, - }, - }); - } - - function logMenu() { - E.showMenu({ - '': { - title: 'Log', - back: topMenu, - }, - 'Max entries': { - value: SETTINGS.maxLogLength, - min: 5, max: 100, step: 5, - onchange: v => { - SETTINGS.maxLogLength = v; - stampLog.maxLength = v; - } - }, - 'Auto-delete oldest': { - value: SETTINGS.rotateLog, - onchange: v => { - SETTINGS.rotateLog = !SETTINGS.rotateLog; - } - }, - 'Clear log': clearLogPrompt, - }); - } - - function appearanceMenu() { - E.showMenu({ - '': { - title: 'Appearance', - back: topMenu, - }, - 'Log font': { - value: fonts.indexOf(SETTINGS.logFont), - min: 0, max: fonts.length - 1, - format: v => fonts[v], - onchange: v => { - SETTINGS.logFont = fonts[v]; - }, - }, - 'Log font H size': { - value: SETTINGS.logFontHSize, - min: 1, max: 50, - onchange: v => { - SETTINGS.logFontHSize = v; - }, - }, - 'Log font V size': { - value: SETTINGS.logFontVSize, - min: 1, max: 50, - onchange: v => { - SETTINGS.logFontVSize = v; - }, - }, - }); - } - - function endMenu() { - saveSettings(); - currentUI.start(); - } - - function clearLogPrompt() { - E.showPrompt('Erase ALL log entries?', { - title: 'Clear log', - buttons: {'Erase':1, "Don't":0} - }).then((yes) => { - if (yes) { - stampLog.deleteEntries(stampLog.log) - endMenu(); - } else { - logMenu(); - } - }); - } - +function switchUI(newUI) { currentUI.stop(); - topMenu(); + currentUI = newUI; + currentUI.start(); } @@ -725,17 +492,29 @@ function saveErrorAlert() { } -function switchUI(newUI) { +function launchSettingsMenu(backCb) { currentUI.stop(); - currentUI = newUI; + stampLog.save(); + tsl.launchSettingsMenu(backCb); +} + +function returnFromSettings() { + // Reload stampLog to pick up any changes made from settings UI + stampLog = loadStampLog(); currentUI.start(); } +function loadStampLog() { + // Create a StampLog object with its data loaded from storage + return new tsl.StampLog(tsl.LOG_FILENAME, tsl.SETTINGS.maxLogLength); +} + + Bangle.loadWidgets(); Bangle.drawWidgets(); -stampLog = new StampLog(LOG_FILENAME, SETTINGS.maxLogLength); +var stampLog = loadStampLog(); E.on('kill', stampLog.save.bind(stampLog)); stampLog.on('saveError', saveErrorAlert); diff --git a/apps/timestamplog/lib.js b/apps/timestamplog/lib.js new file mode 100644 index 000000000..13cb291ef --- /dev/null +++ b/apps/timestamplog/lib.js @@ -0,0 +1,245 @@ +const storage = require('Storage'); + +// Storage filenames + +const LOG_FILENAME = 'timestamplog.json'; +const SETTINGS_FILENAME = 'timestamplog.settings.json'; + + +// Settings + +const SETTINGS = Object.assign({ + logFont: '12x20', + logFontHSize: 1, + logFontVSize: 1, + maxLogLength: 30, + rotateLog: false, + buttonAction: 'Log time', +}, storage.readJSON(SETTINGS_FILENAME, true) || {}); + +const SETTINGS_BUTTON_ACTION = [ + 'Log time', + 'Open settings', + 'Quit app', + 'Do nothing', +]; + + +function fontSpec(name, hsize, vsize) { + return name + ':' + hsize + 'x' + vsize; +} + + +//// Data models //// + +// High-level timestamp log object that provides an interface to the +// UI for managing log entries and automatically loading/saving +// changes to flash storage. +class StampLog { + constructor(filename, maxLength) { + // Name of file to save log to + this.filename = filename; + // Maximum entries for log before old entries are overwritten with + // newer ones + this.maxLength = maxLength; + + // `true` when we have changes that need to be saved + this.isDirty = false; + // Wait at most this many msec upon first data change before + // saving (this is to avoid excessive writes to flash if several + // changes happen quickly; we wait a little bit so they can be + // rolled into a single write) + this.saveTimeout = 30000; + // setTimeout ID for scheduled save job + this.saveId = null; + // Underlying raw log data object. Outside this class it's + // recommended to use only the class methods to change it rather + // than modifying the object directly to ensure that changes are + // recognized and saved to storage. + this.log = []; + + this.load(); + } + + // Read in the log data that is currently in storage + load() { + let log = storage.readJSON(this.filename, true); + if (!log) log = []; + // Convert stringified datetimes back into Date objects + for (let logEntry of log) { + logEntry.stamp = new Date(logEntry.stamp); + } + this.log = log; + } + + // Write current log data to storage if anything needs to be saved + save() { + // Cancel any pending scheduled calls to save() + if (this.saveId) { + clearTimeout(this.saveId); + this.saveId = null; + } + + if (this.isDirty) { + let logToSave = []; + for (let logEntry of this.log) { + // Serialize each Date object into an ISO string before saving + let newEntry = Object.assign({}, logEntry); + newEntry.stamp = logEntry.stamp.toISOString(); + logToSave.push(newEntry); + } + + if (storage.writeJSON(this.filename, logToSave)) { + console.log('stamplog: save to storage completed'); + this.isDirty = false; + } else { + console.log('stamplog: save to storage FAILED'); + this.emit('saveError'); + } + } else { + console.log('stamplog: skipping save to storage because no changes made'); + } + } + + // Mark log as needing to be (re)written to storage + setDirty() { + this.isDirty = true; + if (!this.saveId) { + this.saveId = setTimeout(this.save.bind(this), this.saveTimeout); + } + } + + // Add a timestamp for the current time to the end of the log + addEntry() { + // If log full, purge an old entry to make room for new one + if (this.maxLength) { + while (this.log.length + 1 > this.maxLength) { + this.log.shift(); + } + } + // Add new entry + this.log.push({ + stamp: new Date() + }); + this.setDirty(); + } + + // Delete the log objects given in the array `entries` from the log + deleteEntries(entries) { + this.log = this.log.filter(entry => !entries.includes(entry)); + this.setDirty(); + } + + // Does the log currently contain the maximum possible number of entries? + isFull() { + return this.log.length >= this.maxLength; + } +} + +function launchSettingsMenu(backCb) { + const fonts = g.getFonts(); + const stampLog = new StampLog(LOG_FILENAME, SETTINGS.maxLogLength); + + function saveSettings() { + stampLog.save(); + if (!storage.writeJSON(SETTINGS_FILENAME, SETTINGS)) { + E.showAlert('Trouble saving settings'); + } + } + + function endMenu() { + saveSettings(); + backCb(); + } + + function topMenu() { + E.showMenu({ + '': { + title: 'Stamplog', + back: endMenu, + }, + 'Log': logMenu, + 'Appearance': appearanceMenu, + 'Button': { + value: SETTINGS_BUTTON_ACTION.indexOf(SETTINGS.buttonAction), + min: 0, max: SETTINGS_BUTTON_ACTION.length - 1, + format: v => SETTINGS_BUTTON_ACTION[v], + onchange: v => { + SETTINGS.buttonAction = SETTINGS_BUTTON_ACTION[v]; + }, + }, + }); + } + + function logMenu() { + E.showMenu({ + '': { + title: 'Log', + back: topMenu, + }, + 'Max entries': { + value: SETTINGS.maxLogLength, + min: 5, max: 100, step: 5, + onchange: v => { + SETTINGS.maxLogLength = v; + stampLog.maxLength = v; + } + }, + 'Auto-delete oldest': { + value: SETTINGS.rotateLog, + onchange: v => { + SETTINGS.rotateLog = !SETTINGS.rotateLog; + } + }, + 'Clear log': doClearLog, + }); + } + + function appearanceMenu() { + E.showMenu({ + '': { + title: 'Appearance', + back: topMenu, + }, + 'Log font': { + value: fonts.indexOf(SETTINGS.logFont), + min: 0, max: fonts.length - 1, + format: v => fonts[v], + onchange: v => { + SETTINGS.logFont = fonts[v]; + }, + }, + 'Log font H size': { + value: SETTINGS.logFontHSize, + min: 1, max: 50, + onchange: v => { + SETTINGS.logFontHSize = v; + }, + }, + 'Log font V size': { + value: SETTINGS.logFontVSize, + min: 1, max: 50, + onchange: v => { + SETTINGS.logFontVSize = v; + }, + }, + }); + } + + function doClearLog() { + E.showPrompt('Erase ALL log entries?', { + title: 'Clear log', + buttons: {'Erase':1, "Don't":0} + }).then((yes) => { + if (yes) { + stampLog.deleteEntries(stampLog.log); + } + logMenu(); + }); + } + + topMenu(); +} + +exports = {LOG_FILENAME, SETTINGS_FILENAME, SETTINGS, SETTINGS_BUTTON_ACTION, fontSpec, StampLog, + launchSettingsMenu}; diff --git a/apps/timestamplog/metadata.json b/apps/timestamplog/metadata.json index 858fab237..d5df5c54b 100644 --- a/apps/timestamplog/metadata.json +++ b/apps/timestamplog/metadata.json @@ -10,7 +10,8 @@ "interface": "interface.html", "storage": [ {"name": "timestamplog.app.js", "url": "app.js"}, - {"name": "timestamplog.img", "url": "app-icon.js", "evaluate": true} + {"name": "timestamplog.img", "url": "app-icon.js", "evaluate": true}, + {"name": "lib.js", "url": "lib.js"} ], "data": [ {"name": "timestamplog.settings"}, diff --git a/apps/timestamplog/settings.js b/apps/timestamplog/settings.js new file mode 100644 index 000000000..137ed31db --- /dev/null +++ b/apps/timestamplog/settings.js @@ -0,0 +1,7 @@ +const tsl = require('timestamplog'); + +( + function(backCb) { + tsl.launchSettingsMenu(backCb); + } +);