forked from FOSS/BangleApps
524 lines
14 KiB
JavaScript
524 lines
14 KiB
JavaScript
const Layout = require('Layout');
|
|
const locale = require('locale');
|
|
|
|
const tsl = require('timestamplog');
|
|
|
|
|
|
// Min number of pixels of movement to recognize a touchscreen drag/swipe
|
|
const DRAG_THRESHOLD = 30;
|
|
|
|
// Width of scroll indicators
|
|
const SCROLL_BAR_WIDTH = 12;
|
|
|
|
|
|
// 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";
|
|
}
|
|
}
|
|
|
|
|
|
// 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(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)
|
|
.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);
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
|
|
constructor() {
|
|
// Values set up by start()
|
|
this.itemsPerPage = null;
|
|
this.scrollPos = null;
|
|
this.layout = null;
|
|
|
|
// Handlers/listeners
|
|
this.buttonTimeoutId = null;
|
|
this.listeners = {};
|
|
}
|
|
|
|
// Launch this UI and make it live
|
|
start() {
|
|
this._initLayout();
|
|
this.layout.clear();
|
|
this.scroll('b');
|
|
this.render('buttons');
|
|
|
|
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);
|
|
clearWatch(this.listeners.btnWatch);
|
|
Bangle.setUI();
|
|
}
|
|
|
|
_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},
|
|
|
|
{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()); }
|
|
},
|
|
],
|
|
},
|
|
{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',
|
|
cb: () => launchSettingsMenu(returnFromSettings)},
|
|
],
|
|
},
|
|
],
|
|
}
|
|
);
|
|
|
|
// Calculate how many log items per page we have space to display
|
|
layout.update();
|
|
let availableHeight = layout.placeholder.h;
|
|
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));
|
|
|
|
// Populate log items in layout
|
|
for (let i = 0; i < this.itemsPerPage; i++) {
|
|
layout.logItems.c.push(
|
|
{type: 'custom', render: renderLogItem, item: undefined, itemIdx: undefined,
|
|
fillx: 1, height: logItemHeight}
|
|
);
|
|
}
|
|
layout.logScroll.height = logItemHeight * this.itemsPerPage;
|
|
layout.logScroll.width = SCROLL_BAR_WIDTH;
|
|
layout.update();
|
|
|
|
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;
|
|
let logIdx = this.scrollPos - this.itemsPerPage;
|
|
for (let elem of layLogItems.c) {
|
|
logIdx++;
|
|
elem.item = stampLog.log[logIdx];
|
|
elem.itemIdx = logIdx;
|
|
}
|
|
this.layout.render(layLogItems);
|
|
this.layout.render(this.layout.logScroll);
|
|
}
|
|
|
|
if (!item || item == 'buttons') {
|
|
let addBtn = this.layout.addBtn;
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
// 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)
|
|
);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
_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;
|
|
}
|
|
}
|
|
|
|
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(stampLog, logUIObj.itemIdx));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.listeners.touch = touchHandler.bind(this);
|
|
Bangle.on('touch', this.listeners.touch);
|
|
|
|
function buttonHandler() {
|
|
let act = tsl.SETTINGS.buttonAction;
|
|
if (act == 'Log time') {
|
|
if (currentUI != mainUI) {
|
|
switchUI(mainUI);
|
|
}
|
|
mainUI.addTimestamp();
|
|
} else if (act == 'Open settings') {
|
|
launchSettingsMenu(returnFromSettings);
|
|
} else if (act == 'Quit app') {
|
|
Bangle.showClock();
|
|
}
|
|
}
|
|
|
|
this.listeners.btnWatch = setWatch(buttonHandler, BTN,
|
|
{edge: 'falling', debounce: 50, repeat: true});
|
|
}
|
|
|
|
// Add current timestamp to log if possible and update UI display
|
|
addTimestamp() {
|
|
if (tsl.SETTINGS.rotateLog || !stampLog.isFull()) {
|
|
stampLog.addEntry();
|
|
this.scroll('b');
|
|
this.render('buttons');
|
|
}
|
|
}
|
|
|
|
// Get scroll information for log display
|
|
scrollInfo() {
|
|
return {
|
|
pos: this.scrollPos,
|
|
min: (stampLog.log.length - 1) % this.itemsPerPage,
|
|
max: stampLog.log.length - 1,
|
|
itemsPerPage: this.itemsPerPage
|
|
};
|
|
}
|
|
|
|
// 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
|
|
scroll(how) {
|
|
let scroll = this.scrollInfo();
|
|
|
|
if (how == 'u') {
|
|
this.scrollPos -= scroll.itemsPerPage;
|
|
} else if (how == 'd') {
|
|
this.scrollPos += scroll.itemsPerPage;
|
|
} else if (how == 't') {
|
|
this.scrollPos = scroll.min;
|
|
} else if (how == 'b') {
|
|
this.scrollPos = scroll.max;
|
|
}
|
|
|
|
this.scrollPos = E.clip(this.scrollPos, scroll.min, scroll.max);
|
|
|
|
this.render('log');
|
|
}
|
|
}
|
|
|
|
|
|
// Log entry screen interface, launched by calling start()
|
|
class LogEntryScreen {
|
|
|
|
constructor(stampLog, logIdx) {
|
|
this.logIdx = logIdx;
|
|
this.logItem = stampLog.log[logIdx];
|
|
|
|
this.defaultFont = tsl.fontSpec(
|
|
tsl.SETTINGS.logFont, tsl.SETTINGS.logFontHSize, tsl.SETTINGS.logFontVSize);
|
|
}
|
|
|
|
start() {
|
|
this._initLayout();
|
|
this.layout.clear();
|
|
this.refresh();
|
|
}
|
|
|
|
stop() {
|
|
Bangle.setUI();
|
|
}
|
|
|
|
back() {
|
|
this.stop();
|
|
switchUI(mainUI);
|
|
}
|
|
|
|
_initLayout() {
|
|
let layout = new Layout(
|
|
{type: 'v',
|
|
c: [
|
|
{type: 'txt', font: this.defaultFont, id: 'entryno', label: 'Entry ?/?'},
|
|
{type: 'txt', font: this.defaultFont, id: 'date', label: '?'},
|
|
{type: 'txt', font: this.defaultFont, id: 'time', label: '?'},
|
|
{type: '', id: 'placeholder', fillx: 1, filly: 1},
|
|
{type: 'btn', font: '12x20', label: 'Delete',
|
|
cb: 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 = stampLog.log[this.logIdx];
|
|
this.layout.entryno.label = 'Entry ' + (this.logIdx+1) + '/' + stampLog.log.length;
|
|
this.layout.date.label = locale.date(this.logItem.stamp, 1);
|
|
this.layout.time.label = locale.time(this.logItem.stamp).trim();
|
|
this.layout.update();
|
|
this.render();
|
|
}
|
|
|
|
prevLogItem() {
|
|
this.logIdx = this.logIdx ? this.logIdx-1 : stampLog.log.length-1;
|
|
this.refresh();
|
|
}
|
|
|
|
nextLogItem() {
|
|
this.logIdx = this.logIdx == stampLog.log.length-1 ? 0 : this.logIdx+1;
|
|
this.refresh();
|
|
}
|
|
|
|
delLogItem() {
|
|
stampLog.deleteEntries([this.logItem]);
|
|
if (!stampLog.log.length) {
|
|
this.back();
|
|
return;
|
|
} 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
|
|
// that the deletion has been performed
|
|
this.layout.clear();
|
|
setTimeout(this.refresh.bind(this), 250);
|
|
}
|
|
|
|
}
|
|
|
|
|
|
function switchUI(newUI) {
|
|
currentUI.stop();
|
|
currentUI = newUI;
|
|
currentUI.start();
|
|
}
|
|
|
|
|
|
function saveErrorAlert() {
|
|
currentUI.stop();
|
|
// 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",
|
|
buttons: {'Ok': true}}
|
|
).then(currentUI.start.bind(currentUI));
|
|
}
|
|
|
|
|
|
function launchSettingsMenu(backCb) {
|
|
currentUI.stop();
|
|
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();
|
|
|
|
var stampLog = loadStampLog();
|
|
E.on('kill', stampLog.save.bind(stampLog));
|
|
stampLog.on('saveError', saveErrorAlert);
|
|
|
|
var currentUI = new MainScreen(stampLog);
|
|
var mainUI = currentUI;
|
|
currentUI.start();
|