1
0
Fork 0
BangleApps/apps/timestamplog/app.js

709 lines
18 KiB
JavaScript
Raw Normal View History

Layout = require('Layout');
locale = require('locale');
storage = require('Storage');
2023-09-09 16:50:23 +00:00
// Storage filenames
const LOG_FILENAME = 'timestamplog.json';
2023-09-09 16:50:23 +00:00
const SETTINGS_FILENAME = 'timestamplog.settings.json';
// Min number of pixels of movement to recognize a touchscreen drag/swipe
const DRAG_THRESHOLD = 30;
2023-08-28 02:53:03 +00:00
// Width of scroll indicators
const SCROLL_BAR_WIDTH = 12;
2023-09-09 16:50:23 +00:00
// Settings
const SETTINGS = Object.assign({
2023-09-10 02:08:01 +00:00
logFont: '12x20',
logFontHSize: 1,
logFontVSize: 1,
maxLogLength: 30,
rotateLog: false,
2023-09-09 16:50:23 +00:00
}, storage.readJSON(SETTINGS_FILENAME, true) || {});
function saveSettings() {
if (!storage.writeJSON(SETTINGS_FILENAME, SETTINGS)) {
2023-09-11 21:18:28 +00:00
E.showAlert('Trouble saving settings');
2023-09-09 16:50:23 +00:00
}
}
function fontSpec(name, hsize, vsize) {
return name + ':' + hsize + 'x' + vsize;
}
// Fetch a stringified image
function getIcon(id) {
if (id == 'add') {
// Graphics.createImage(`
// XX X X X X
// XX X X X X
// XXXXXX X X X X
// XXXXXX X X X X
// XX X X X X
// XX X X X X
// X XX X X
// X X X X
// X XX X
// X X X
// X X
// X XX
// XXX XX
// XXXXX
// XXXX
// XX
// `);
return "\0\x17\x10\x81\x000\t\x12`$K\xF0\x91'\xE2D\x83\t\x12\x06$H\x00\xB1 \x01$\x80\x042\x00\b(\x00 \x00A\x80\x01\xCC\x00\x03\xE0\x00\x0F\x00\x00\x18\x00\x00";
} else if (id == 'menu') {
// Graphics.createImage(`
//
//
//
//
// XXXXXXXXXXXXXXXX
// XXXXXXXXXXXXXXXX
//
//
// XXXXXXXXXXXXXXXX
// XXXXXXXXXXXXXXXX
//
//
// XXXXXXXXXXXXXXXX
// XXXXXXXXXXXXXXXX
//
//
// `);
return "\0\x10\x10\x81\0\0\0\0\0\0\0\0\0\xFF\xFF\xFF\xFF\0\0\0\0\xFF\xFF\xFF\xFF\0\0\0\0\xFF\xFF\xFF\xFF\0\0\0\0";
}
}
//// 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))
.setFontAlign(-1, -1)
.setColor(g.theme.fg)
.drawLine(elem.x, elem.y, elem.x + elem.w - 1, elem.y)
.drawString(locale.date(elem.item.stamp, 1)
+ '\n'
+ locale.time(elem.item.stamp).trim(),
elem.x, elem.y + 1);
} else {
g.setColor(g.blendColor(g.theme.bg, g.theme.fg, 0.25))
.fillRect(elem.x, elem.y, elem.x + elem.w - 1, elem.y + elem.h - 1);
}
}
2023-08-28 02:53:03 +00:00
// Render a scroll indicator
// `scroll` format: {
// pos: int,
// min: int,
// max: int,
// itemsPerPage: int,
// }
function renderScrollBar(elem, scroll) {
const border = 1;
const boxArea = elem.h - 2 * border;
const boxSize = E.clip(
Math.round(
scroll.itemsPerPage / (scroll.max - scroll.min + 1) * (elem.h - 2)
),
3,
boxArea
);
const boxTop = (scroll.max - scroll.min) ?
Math.round(
(scroll.pos - scroll.min) / (scroll.max - scroll.min)
* (boxArea - boxSize) + elem.y + border
) : elem.y + border;
// Draw border
g.setColor(g.theme.fg)
.fillRect(elem.x, elem.y, elem.x + elem.w - 1, elem.y + elem.h - 1)
// Draw scroll box area
.setColor(g.theme.bg)
.fillRect(elem.x + border, elem.y + border,
elem.x + elem.w - border - 1, elem.y + elem.h - border - 1)
// Draw scroll box
.setColor(g.blendColor(g.theme.bg, g.theme.fg, 0.5))
.fillRect(elem.x + border, boxTop,
elem.x + elem.w - border - 1, boxTop + boxSize - 1);
}
// Main app screen interface, launched by calling start()
class MainScreen {
2023-08-26 04:30:54 +00:00
constructor(stampLog) {
this.stampLog = stampLog;
// Values set up by start()
2023-08-28 02:55:40 +00:00
this.itemsPerPage = null;
this.scrollPos = null;
this.layout = null;
// Handlers/listeners
this.buttonTimeoutId = null;
this.listeners = {};
}
// Launch this UI and make it live
start() {
2023-08-26 04:30:54 +00:00
this._initLayout();
this.layout.clear();
2023-08-28 02:55:40 +00:00
this.scroll('b');
2023-08-28 02:58:10 +00:00
this.render('buttons');
2023-08-26 04:30:54 +00:00
this._initTouch();
}
// Stop this UI, shut down all timers/listeners, and otherwise clean up
stop() {
if (this.buttonTimeoutId) {
clearTimeout(this.buttonTimeoutId);
this.buttonTimeoutId = null;
}
// Kill layout handlers
Bangle.removeListener('drag', this.listeners.drag);
Bangle.removeListener('touch', this.listeners.touch);
Bangle.setUI();
}
2023-08-26 04:30:54 +00:00
_initLayout() {
let layout = new Layout(
{type: 'v',
c: [
// Placeholder to force bottom alignment when there is unused
// vertical screen space
{type: '', id: 'placeholder', fillx: 1, filly: 1},
2023-08-28 02:53:03 +00:00
{type: 'h',
c: [
{type: 'v',
id: 'logItems',
// To be filled in with log item elements once we
// determine how many will fit on screen
c: [],
},
{type: 'custom',
id: 'logScroll',
render: elem => { renderScrollBar(elem, this.scrollBarInfo()); }
2023-08-28 02:53:03 +00:00
},
],
},
{type: 'h',
id: 'buttons',
c: [
{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',
2023-09-09 16:50:23 +00:00
cb: settingsMenu},
],
},
],
}
);
// 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));
let logItemHeight = g.getFontHeight() * 2;
this.itemsPerPage = Math.max(1,
Math.floor(availableHeight / logItemHeight));
// Populate log items in layout
2023-08-28 02:55:40 +00:00
for (i = 0; i < this.itemsPerPage; i++) {
layout.logItems.c.push(
{type: 'custom', render: renderLogItem, item: undefined, itemIdx: undefined,
fillx: 1, height: logItemHeight}
);
}
2023-08-28 02:55:40 +00:00
layout.logScroll.height = logItemHeight * this.itemsPerPage;
2023-08-28 02:53:03 +00:00
layout.logScroll.width = SCROLL_BAR_WIDTH;
layout.update();
2023-08-26 04:30:54 +00:00
this.layout = layout;
}
// Redraw a particular display `item`, or everything if `item` is falsey
render(item) {
if (!item || item == 'log') {
let layLogItems = this.layout.logItems;
2023-08-28 02:55:40 +00:00
let logIdx = this.scrollPos - this.itemsPerPage;
for (let elem of layLogItems.c) {
logIdx++;
elem.item = this.stampLog.log[logIdx];
elem.itemIdx = logIdx;
}
this.layout.render(layLogItems);
2023-08-28 02:53:03 +00:00
this.layout.render(this.layout.logScroll);
}
if (!item || item == 'buttons') {
let addBtn = this.layout.addBtn;
if (!SETTINGS.rotateLog && this.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);
addBtn.label = 'Log full';
} else {
addBtn.btnFaceCol = g.theme.bg2;
addBtn.btnBorderCol = g.theme.fg2;
addBtn.label = getIcon('add') + ' ' + locale.time(new Date(), 1).trim();
}
this.layout.render(this.layout.buttons);
2023-08-26 04:30:54 +00:00
// Auto-update time of day indication on log-add button upon
// next minute
if (!this.buttonTimeoutId) {
this.buttonTimeoutId = setTimeout(
() => {
this.buttonTimeoutId = null;
this.render('buttons');
},
60000 - (Date.now() % 60000)
);
}
}
}
2023-08-26 04:30:54 +00:00
_initTouch() {
let distanceY = null;
function dragHandler(ev) {
// Handle up/down swipes for scrolling
if (ev.b) {
if (distanceY === null) {
// Drag started
distanceY = ev.dy;
} else {
// Drag in progress
distanceY += ev.dy;
}
} else {
// Drag released
distanceY = null;
}
if (Math.abs(distanceY) > DRAG_THRESHOLD) {
// Scroll threshold reached
Bangle.buzz(50, .5);
this.scroll(distanceY > 0 ? 'u' : 'd');
distanceY = null;
}
}
2023-08-26 04:30:54 +00:00
this.listeners.drag = dragHandler.bind(this);
Bangle.on('drag', this.listeners.drag);
function touchHandler(button, xy) {
// Handle taps on log entries
let logUIItems = this.layout.logItems.c;
for (var logUIObj of logUIItems) {
if (!xy.type &&
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));
break;
}
}
}
this.listeners.touch = touchHandler.bind(this);
Bangle.on('touch', this.listeners.touch);
}
// Add current timestamp to log if possible and update UI display
addTimestamp() {
if (SETTINGS.rotateLog || !this.stampLog.isFull()) {
this.stampLog.addEntry();
this.scroll('b');
this.render('buttons');
}
}
2023-08-28 02:53:03 +00:00
// Get scroll information for log display
2023-08-28 02:55:40 +00:00
scrollInfo() {
2023-08-28 02:53:03 +00:00
return {
2023-08-28 02:55:40 +00:00
pos: this.scrollPos,
min: (this.stampLog.log.length - 1) % this.itemsPerPage,
2023-08-28 02:53:03 +00:00
max: this.stampLog.log.length - 1,
2023-08-28 02:55:40 +00:00
itemsPerPage: this.itemsPerPage
2023-08-28 02:53:03 +00:00
};
}
// Like scrollInfo, but adjust the data so as to suggest scrollbar
// geometry that accurately reflects the nature of the scrolling
// (page by page rather than item by item)
scrollBarInfo() {
const info = this.scrollInfo();
function toPage(scrollPos) {
return Math.floor(scrollPos / info.itemsPerPage);
}
return {
// Define 1 "screenfull" as the unit here
itemsPerPage: 1,
pos: toPage(info.pos),
min: toPage(info.min),
max: toPage(info.max),
};
}
// Scroll display in given direction or to given position:
// 'u': up, 'd': down, 't': to top, 'b': to bottom
2023-08-28 02:55:40 +00:00
scroll(how) {
let scroll = this.scrollInfo();
if (how == 'u') {
2023-08-28 02:55:40 +00:00
this.scrollPos -= scroll.itemsPerPage;
} else if (how == 'd') {
2023-08-28 02:55:40 +00:00
this.scrollPos += scroll.itemsPerPage;
} else if (how == 't') {
2023-08-28 02:55:40 +00:00
this.scrollPos = scroll.min;
} else if (how == 'b') {
2023-08-28 02:55:40 +00:00
this.scrollPos = scroll.max;
}
2023-08-28 02:55:40 +00:00
this.scrollPos = E.clip(this.scrollPos, scroll.min, scroll.max);
2023-08-28 02:58:10 +00:00
this.render('log');
}
}
// Log entry screen interface, launched by calling start()
class LogEntryScreen {
constructor(stampLog, logIdx) {
this.stampLog = stampLog;
this.logIdx = logIdx;
this.logItem = stampLog.log[logIdx];
this.defaultFont = fontSpec(
SETTINGS.logFont, SETTINGS.logFontHSize, SETTINGS.logFontVSize);
}
start() {
this._initLayout();
this.layout.clear();
this.render();
}
stop() {
Bangle.setUI();
}
back() {
this.stop();
switchUI(mainUI);
}
_initLayout() {
let layout = new Layout(
{type: 'v',
c: [
{type: 'txt', font: this.defaultFont, label: locale.date(this.logItem.stamp, 1)},
{type: 'txt', font: this.defaultFont, label: locale.time(this.logItem.stamp).trim()},
{type: '', id: 'placeholder', fillx: 1, filly: 1},
{type: 'btn', font: '6x15', label: 'Delete', cb: this.delLogItem.bind(this),
cbl: this.delLogItem.bind(this)},
],
},
{
back: this.back.bind(this),
btns: [
{label: '<', cb: this.prevLogItem.bind(this)},
{label: '>', cb: this.nextLogItem.bind(this)},
],
}
);
layout.update();
this.layout = layout;
}
render(item) {
this.layout.clear();
this.layout.render();
}
refresh() {
this.logItem = this.stampLog.log[this.logIdx];
this._initLayout();
this.render();
}
prevLogItem() {
this.logIdx = this.logIdx ? this.logIdx-1 : this.stampLog.log.length-1;
this.refresh();
}
nextLogItem() {
this.logIdx = this.logIdx == this.stampLog.log.length-1 ? 0 : this.logIdx+1;
this.refresh();
}
delLogItem() {
this.stampLog.deleteEntries([this.logItem]);
if (!this.stampLog.log.length) {
this.back();
return;
} else if (this.logIdx > this.stampLog.log.length - 1) {
this.logIdx = this.stampLog.log.length - 1;
}
// Create a brief “blink” on the screen to provide user feedback
// that the deletion has been performed
this.layout.clear();
setTimeout(this.refresh.bind(this), 250);
}
}
2023-09-09 16:50:23 +00:00
function settingsMenu() {
const fonts = g.getFonts();
function topMenu() {
E.showMenu({
'': {
title: 'Stamplog',
back: endMenu,
},
'Log': logMenu,
'Appearance': appearanceMenu,
});
}
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;
},
},
});
}
2023-09-09 16:50:23 +00:00
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();
}
});
}
2023-09-09 16:50:23 +00:00
currentUI.stop();
topMenu();
2023-09-09 16:50:23 +00:00
}
function saveErrorAlert() {
currentUI.stop();
2023-08-26 04:30:54 +00:00
// Not `showAlert` because the icon plus message don't fit the
// screen well
E.showPrompt(
'Trouble saving timestamp log; data may be lost!',
{title: "Can't save log",
2023-08-26 04:30:54 +00:00
buttons: {'Ok': true}}
).then(currentUI.start.bind(currentUI));
}
function switchUI(newUI) {
currentUI.stop();
currentUI = newUI;
currentUI.start();
}
Bangle.loadWidgets();
Bangle.drawWidgets();
2023-09-09 16:50:23 +00:00
stampLog = new StampLog(LOG_FILENAME, SETTINGS.maxLogLength);
E.on('kill', stampLog.save.bind(stampLog));
stampLog.on('saveError', saveErrorAlert);
var currentUI = new MainScreen(stampLog);
var mainUI = currentUI;
currentUI.start();