Merge pull request #2818 from philipandresen/wrkmem
[wrkmem] A working memory prosthesispull/2826/head
|
@ -0,0 +1 @@
|
|||
1.00: Implement Working Memory Helper app
|
|
@ -0,0 +1,60 @@
|
|||
# Working Memory Helper
|
||||
Human brains keep track of what they are doing in a conceptual space known as "working memory". Older adults and people
|
||||
of all ages with ADHD often struggle to maintain information in their working memories, causing them to forget what
|
||||
they were doing only moments after deciding to do it. One excellent way to combat this symptom is to externalize your
|
||||
working memory.
|
||||
|
||||
This app doesn't completely externalize and replace working memory, but it does act as a prosthesis for the task
|
||||
management aspect of working memory. The workflow looks something like this:
|
||||
|
||||
1. Decide to do something. (If you can't get this far on your own, this app is not gonna help.)
|
||||
2. Immediately enter a brief prompt in the app as a "task". For example, if you were going to take out the trash,
|
||||
you might write "Trash". If you were going to take your car to the mechanic, you might write "car", or "mechanic". It
|
||||
doesn't have to remind you what you were doing a week from now, only a minute or so, so it can be very simple / brief.
|
||||
3. Thirty seconds after you enter the task into the app, your device will vibrate and ask you if you are on task, or if
|
||||
you got distracted.
|
||||
1. If you are on task, hit "On task" and the app will wait a little longer before reminding you again.
|
||||
2. If you got distracted, hit "distracted" and the app will remind you a little sooner next time.
|
||||
4. Continue getting reminders from your watch at various intervals until you complete the task, then tell the app the
|
||||
task is complete. Repeat this process for every single thing you do until you die, basically.
|
||||
|
||||
   
|
||||
 
|
||||
|
||||
## Requirements
|
||||
You must have some kind of keyboard library available in order to enter task descriptions on your device. This app is
|
||||
only supported on BangleJS2
|
||||
|
||||
## Styling
|
||||
This app attempts to match whatever theme your Bangle watch is using. You can also modify whether individual
|
||||
words are wrapped and whether outlines are drawn on text.
|
||||
|
||||
## Task settings
|
||||
You can edit the settings of any individual task. You can rename the task, restart (un-complete) the task, or change
|
||||
some of the reminder cadence settings. As far as cadence, there are a couple that warrant explanation:
|
||||
|
||||
### Interval
|
||||
This is the base reminder interval for your task. If it is 30, your first reminder will be after 30 seconds.
|
||||
|
||||
### Incremental Backoff
|
||||
Incremental backoff is a strategy for timing the reminder notifications you get based on how well you stay on task.
|
||||
Each time you affirm that you are "on task", incremental backoff means it will wait longer before reminding you again.
|
||||
Similarly each time you affirm that you are "distracted" the incremental backoff will wait less time before reminding
|
||||
you again. The exact intervals are multiples of the base interval. For a task with a base interval of 30 seconds, the
|
||||
second reminder would be after 60 seconds. The third after 120 seconds, etc. Then if you got distracted it would go
|
||||
back to 60, then 30, then 15. Typically the interval will never go below 1/2 of your base interval.
|
||||
|
||||
If you disable Incremental Backoff, you will be reminded once every base interval no matter what you do. This can be
|
||||
handy if you are having trouble staying on task when the intervals get too long with incremental backoff.
|
||||
|
||||
## Controls
|
||||
A large focus of this app was making clear affordances for the user interface. Anything that can be pressed should look
|
||||
like a button, however you may notice some small arrows and text on the sides / top / bottom of the screen in some
|
||||
cases. These hints are there to tell you that you can swipe across the screen to perform additional actions.
|
||||
Swipe your finger anywhere on the screen in the direction the arrow is pointing to use the listed function.
|
||||
|
||||
## Known issues
|
||||
The clock is not super-duper accurate because it only updates when the screen refreshes (which can be 30 seconds or 5
|
||||
minutes apart). I put it on there, though, because it is more useful to be there and lagging by 30+ seconds than to be
|
||||
not there at all. I plan to fix this problem eventually but it's secondary to the main functions of the app at the
|
||||
moment.
|
|
@ -0,0 +1 @@
|
|||
require("heatshrink").decompress(atob("mEwgYJGgVJkmQDZt/////wRfgVP8g1OiQRByQRNyQRCoAMHgP8wEAk5GBAAPJgET/IREh//yVJCAYABkmf/8gCIc///yNIQAD/gCB8ARFAAQmBkmTA4YREGoIvCJQIABHYY1EhIHB/DFDCgItB/IREgM//x3Byg2BiUAggRHLINAgsvFAOkC4JrGCIKtBmZCC/2IR4IRFLILRBCIf/6MAgf/8gREWYUz/J8CCIMAv4REAoIRBg1CpMmv/6GQNPDoQXGHAMA54RCF4Y7HAAOvCIZTCCIRfECIp3BCLpHDCJcJNYJ9CCJdSpaPDCJUDe4LFCCJ2odIIRN9USeogRK9C/DCLDFDCIzFEdIoRHfYwFDsmESodPCIgXFqVECId/CIpNEGohTFL4WSCI8T/8kCIvyVoIRFz7jFgE//1ICIsE//5CAkAg4IBDQJrCgLbB5ARFDQI+BwEJCgNJfwIsBAAsfCQOSpMkyYFB+QQGgECv4MBAAf8YQYAFhIRFXggAGk4QD5J6FAAxHCySVBAHQ="))
|
|
@ -0,0 +1,688 @@
|
|||
const textInput = require("textinput");
|
||||
|
||||
g.reset();
|
||||
g.clearRect(Bangle.appRect);
|
||||
E.showMessage("Loading ... ");
|
||||
Bangle.loadWidgets();
|
||||
Bangle.drawWidgets();
|
||||
|
||||
const localTaskFile = "wrkmem.json";
|
||||
const savedData = {
|
||||
tasks: [], keyboardAlpha: undefined, settings: {textOutlines: true, noWordBreaks: true}
|
||||
};
|
||||
|
||||
Object.assign(savedData, require("Storage")
|
||||
.readJSON(localTaskFile, true) || {});
|
||||
|
||||
let currentMenu;
|
||||
|
||||
const allTasks = savedData.tasks;
|
||||
const SWIPE = {
|
||||
LEFT: 2, RIGHT: 0, UP: 3, DOWN: 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* A management object that helps us keep track of all our task timeouts.
|
||||
* @type {{queueResponseTimeout: nudgeManager.queueResponseTimeout, taskTimeout: null, queueNudge:
|
||||
* nudgeManager.queueNudge, interrupt: nudgeManager.interrupt, responseTimeout: null, activeTask: null}}
|
||||
*/
|
||||
const nudgeManager = {
|
||||
activeTask : null, taskTimeout: null, responseTimeout: null, interrupt: () => {
|
||||
if (this.taskTimeout) clearTimeout(this.taskTimeout);
|
||||
if (this.responseTimeout) clearTimeout(this.responseTimeout);
|
||||
this.activeTask = null;
|
||||
}, queueNudge : (task, nudgeFn) => {
|
||||
if (this.responseTimeout) clearTimeout(this.responseTimeout);
|
||||
if (this.taskTimeout) clearTimeout(this.taskTimeout);
|
||||
this.activeTask = task;
|
||||
const backoffIndex = task.useBackoff ? task.backoffIndex : 1;
|
||||
const time = task.incrementalBackoffSet[backoffIndex] * task.interval * 1000;
|
||||
this.taskTimeout = setTimeout(nudgeFn, time);
|
||||
}, queueResponseTimeout: (defaultFn) => {
|
||||
// This timeout shouldn't be set if we've queued a response timeout, but we clear it anyway.
|
||||
if (this.taskTimeout) clearTimeout(this.taskTimeout);
|
||||
this.responseTimeout = setTimeout(defaultFn, 15000);
|
||||
},
|
||||
}
|
||||
|
||||
let keyboardAlpha, keyboardAlphaShift;
|
||||
|
||||
if (textInput.generateKeyboard) {
|
||||
//const charSet = textInput.createCharSet("ABCDEFGHIJKLMNOPQRSTUVWXYZ", ["spc", "ok", "del"]);
|
||||
keyboardAlpha = textInput.generateKeyboard([
|
||||
["a", "b", "c", "j", "k", "l", "s", "t", "u"],
|
||||
["d", "e", "f", "m", "n", "o", "v", "w", "x"],
|
||||
["g", "h", "i", "p", "q", "r", "y", "z"],
|
||||
"spc",
|
||||
"ok",
|
||||
"del"
|
||||
]);
|
||||
keyboardAlphaShift = textInput.generateKeyboard([
|
||||
["A", "B", "C", "J", "K", "L", "S", "T", "U"],
|
||||
["D", "E", "F", "M", "N", "O", "V", "W", "X"],
|
||||
["G", "H", "I", "P", "Q", "R", "Y", "Z"],
|
||||
"spc",
|
||||
"cncl",
|
||||
"del"
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the data in 'savedData' to flash memory.
|
||||
*/
|
||||
function save() {
|
||||
require("Storage")
|
||||
.writeJSON("wrkmem.json", savedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a position and set of dimensions, create a button object that represents a rectangle in space containing text
|
||||
* and some associated functionality.
|
||||
* @param x
|
||||
* @param y
|
||||
* @param w
|
||||
* @param h
|
||||
* @param text
|
||||
* @param callback
|
||||
* @returns {{padding: number, r: number, getDrawable: (function(*, *, *, *, *): {r, x, y, y2, x2}), w, x, h, y, text:
|
||||
* string, onTouch: ((function(*, *): (*|undefined))|*)}}
|
||||
*/
|
||||
function createButton(x, y, w, h, text, callback) {
|
||||
text = text || "";
|
||||
const x2 = x + w;
|
||||
const y2 = y + h;
|
||||
const r = 8;
|
||||
const padding = 2;
|
||||
const getDrawable = (xOff, yOff, wOff, hOff, rOff) => {
|
||||
xOff = xOff || 0;
|
||||
yOff = yOff || 0;
|
||||
wOff = wOff || 0;
|
||||
hOff = hOff || 0;
|
||||
rOff = rOff || 0;
|
||||
return {x: x + xOff, y: y + yOff, x2: x2 + wOff, y2: y2 + hOff, r: r + rOff};
|
||||
};
|
||||
let onTouch;
|
||||
if (callback) {
|
||||
onTouch = (button, xy) => {
|
||||
const isTouched = x < xy.x && x2 > xy.x && y < xy.y && y2 > xy.y;
|
||||
if (isTouched) {
|
||||
Bangle.buzz(30, 1);
|
||||
return callback();
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
x, y, w, h, r, text, getDrawable, padding, onTouch
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a button object, draw that button onto the screen. This includes the background, borders, effects, and text.
|
||||
* @param button
|
||||
*/
|
||||
function drawButton(button) {
|
||||
const textMaxWidth = button.w - 2 * button.padding;
|
||||
let textOutlineCol = g.theme.bgH;
|
||||
let textCol = g.theme.fg;
|
||||
if (button.onTouch) {
|
||||
g.setColor(g.theme.fg)
|
||||
.fillRect(button.getDrawable());
|
||||
g.setColor(g.theme.bg)
|
||||
.fillRect(button.getDrawable(2, 1, -1, -1));
|
||||
g.setColor(g.theme.bg2)
|
||||
.fillRect(button.getDrawable(3, 3, -3, -3));
|
||||
textOutlineCol = g.theme.bg;
|
||||
textCol = g.theme.fg;
|
||||
}
|
||||
const font = getBestFontForButton(button);
|
||||
// Wrap sometimes adds a line break at the beginning if your string is very small.
|
||||
// So we filter out any elements that are empty.
|
||||
const wrapText = g.setFont(font)
|
||||
.wrapString(button.text, textMaxWidth)
|
||||
.filter(t => !!t)
|
||||
.join("\n");
|
||||
if (savedData.settings.textOutlines) {
|
||||
g.setFontAlign(0, 0)
|
||||
.setColor(textOutlineCol)
|
||||
.drawString(wrapText, button.x + button.w / 2 + 1, button.y + button.h / 2 - 1, false);
|
||||
g.setFontAlign(0, 0)
|
||||
.setColor(textOutlineCol)
|
||||
.drawString(wrapText, button.x + button.w / 2 - 1, button.y + button.h / 2 - 1, false);
|
||||
g.setFontAlign(0, 0)
|
||||
.setColor(textOutlineCol)
|
||||
.drawString(wrapText, button.x + button.w / 2 - 1, button.y + button.h / 2 + 1, false);
|
||||
g.setFontAlign(0, 0)
|
||||
.setColor(textOutlineCol)
|
||||
.drawString(wrapText, button.x + button.w / 2 + 1, button.y + button.h / 2 + 1, false);
|
||||
}
|
||||
g.setFontAlign(0, 0)
|
||||
.setColor(textCol)
|
||||
.drawString(wrapText, button.x + button.w / 2, button.y + button.h / 2, false);
|
||||
g.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a button object, determine what font would be best to display the button's text without breaching the
|
||||
* dimensions of the button itself. Not perfectly at the moment, but serviceably.
|
||||
* @param button
|
||||
* @returns {string}
|
||||
*/
|
||||
function getBestFontForButton(button) {
|
||||
const allowedFonts = ["12x20", "6x15", "6x8", "4x6"];
|
||||
let stringMet = g.setFont("Vector:100")
|
||||
.stringMetrics(button.text);
|
||||
let stringArea = stringMet.width * stringMet.height;
|
||||
const sampleMetric = g.setFont("Vector:100")
|
||||
.stringMetrics("D");
|
||||
const vectorRatio = sampleMetric.height / sampleMetric.width;
|
||||
// Effective height helps us handle tall skinny buttons, since text is usually horizontal.
|
||||
let effectiveHeight = Math.min(button.h, button.w);
|
||||
if (!button.text.includes(" ") && savedData.settings.noWordBreaks) {
|
||||
effectiveHeight = effectiveHeight / vectorRatio
|
||||
}
|
||||
const buttonArea = button.w * effectiveHeight;
|
||||
const ratio = stringArea / buttonArea;
|
||||
const vecSize = Math.floor(100 / ratio);
|
||||
if (vecSize > 20) {
|
||||
return "Vector:" + vecSize;
|
||||
}
|
||||
let i;
|
||||
for (i = 0; i < allowedFonts.length - 1; i++) {
|
||||
stringMet = g.setFont(allowedFonts[i])
|
||||
.stringMetrics(button.text);
|
||||
stringArea = Math.max(stringMet.width, button.w) * stringMet.height;
|
||||
if (stringArea < buttonArea * 0.8) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return allowedFonts[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a rotation (0-3) and a label, create an object representing a swipe hint, complete with draw instructions,
|
||||
* a handler, and the specified rotation.
|
||||
* @param rot A number, preferably from the SWIPE enum. 0 = 0 degrees. 1 = 90 degrees. 2 = 180 degrees, etc.
|
||||
* @param text The text to display on the swipe hint
|
||||
* @param callback The function to be called when the corresponding direction is swiped.
|
||||
* @returns {{rot, onSwipe: ((function(*, *): (*|undefined))|*), draw: draw}}
|
||||
*/
|
||||
function createSwipeControl(rot, text, callback) {
|
||||
let draw = () => {};
|
||||
let appRect = Bangle.appRect;
|
||||
let isSwiped = () => {};
|
||||
switch (rot) {
|
||||
case 0:
|
||||
draw = () => drawSwipeHint(appRect.x + appRect.w / 2, appRect.y + appRect.h - 6, 0, text);
|
||||
isSwiped = (LR, UD) => LR === 1;
|
||||
break;
|
||||
case 1:
|
||||
case -3:
|
||||
draw = () => drawSwipeHint(appRect.x + 6, appRect.y + appRect.h / 2, 1, text);
|
||||
isSwiped = (LR, UD) => UD === 1;
|
||||
break;
|
||||
case 2:
|
||||
case -2:
|
||||
draw = () => drawSwipeHint(appRect.x + appRect.w / 2, appRect.y + appRect.h - 16, 0, text, true);
|
||||
isSwiped = (LR, UD) => LR === -1;
|
||||
break;
|
||||
case 3:
|
||||
case -1:
|
||||
draw = () => drawSwipeHint(appRect.x + appRect.w - 6, appRect.y + appRect.h / 2, 3, text);
|
||||
isSwiped = (LR, UD) => UD === -1;
|
||||
break;
|
||||
}
|
||||
const onSwipe = (LR, UD) => {
|
||||
if (isSwiped(LR, UD)) {
|
||||
return callback();
|
||||
}
|
||||
}
|
||||
return {draw, onSwipe, rot};
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a position, rotation, text, and mirror option, draw a swipe hint on the screen.
|
||||
* @param x The x position of the center of the swipe hint.
|
||||
* @param y The y position of the center of the swipe hint.
|
||||
* @param rot The SWIPE rotation enumerated value (0-3) indicating the direction.
|
||||
* @param text The text to display in the hint.
|
||||
* @param flip Whether or not to flip the direction of the swipe hint (left to right, up to down, etc).
|
||||
*/
|
||||
function drawSwipeHint(x, y, rot, text, flip) {
|
||||
const tw = g.setFont("6x8")
|
||||
.stringWidth(text);
|
||||
const w = tw + 41;
|
||||
const gRot = Graphics.createArrayBuffer(w, 8, 1, {msb: true});
|
||||
gRot.setFont("6x8")
|
||||
.setFontAlign(0, -1)
|
||||
.drawString(text, w / 2, 1);
|
||||
gRot.drawLine(0, 4, (w - tw) / 2 - 4, 4);
|
||||
gRot.drawLine((w + tw + 4) / 2, 4, w, 4);
|
||||
if (flip) {
|
||||
gRot.drawLine(0, 4, 4, 1);
|
||||
gRot.drawLine(0, 4, 4, 7);
|
||||
} else {
|
||||
gRot.drawLine(w, 4, w - 4, 1);
|
||||
gRot.drawLine(w, 4, w - 4, 7);
|
||||
}
|
||||
g.setColor(g.theme.fg)
|
||||
.drawImage(gRot, x, y, {rotate: Math.PI / 2 * rot});
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a set of options, create a drawable / UI-able menu object that attempts to lay out buttons and swipe hints in
|
||||
* a given space. Returns an object with both setUI and render functions.
|
||||
* @param options
|
||||
* @returns {{setUI: (function(): void), buttons: *[], render: render}}
|
||||
*/
|
||||
function createMenu(options) {
|
||||
let width = options.width || Bangle.appRect.w;
|
||||
let height = options.height || Bangle.appRect.h;
|
||||
let offsetY = Bangle.appRect.y;
|
||||
const spaceBetween = options.spaceBetween || 5;
|
||||
const spaceAround = options.spaceAround || 5;
|
||||
const titleFont = options.titleFont || "12x20";
|
||||
let marginTop = 0;
|
||||
let marginBottom = 0;
|
||||
let marginLeft = 0;
|
||||
let marginRight = 0;
|
||||
const swipeControls = options.swipeControls || [];
|
||||
// Add some margin space to fit swipe control hints if they exist.
|
||||
swipeControls.forEach(control => {
|
||||
if (control.rot === 0) marginBottom += 8;
|
||||
if (control.rot === 1) marginLeft += 8;
|
||||
if (control.rot === 2) marginBottom += 8;
|
||||
if (control.rot === 3) marginRight += 8;
|
||||
});
|
||||
// Add top margin to fit the title.
|
||||
if (options.title) {
|
||||
const mets = g.setFont(titleFont)
|
||||
.stringMetrics(options.title);
|
||||
marginTop += mets.height;
|
||||
}
|
||||
height = height - marginTop - marginBottom;
|
||||
width = width - marginLeft - marginRight;
|
||||
const isHorizontal = !!options.isHorizontal;
|
||||
const numGridSpaces = options.items.reduce((acc, item) => (acc + (item.size || 1)), 0);
|
||||
const shortDim = isHorizontal ? width : height;
|
||||
const length = ((shortDim - spaceBetween) / numGridSpaces) - spaceAround;
|
||||
const buttons = [];
|
||||
// currentGrid tracks what grid square we are covering. Any item may cover multiple grid squares.
|
||||
let currentGrid = 0;
|
||||
options.items.forEach((item) => {
|
||||
let x, y, w, h;
|
||||
const mySize = item.size || 1;
|
||||
// myLength represents the shorter of the two dimensions of the button (depending on menu orientation, w / h).
|
||||
const myLength = length * mySize + spaceBetween * (mySize - 1);
|
||||
if (isHorizontal) {
|
||||
x = spaceAround + currentGrid * (length + spaceBetween) + marginLeft;
|
||||
y = spaceAround + marginTop + offsetY;
|
||||
w = myLength;
|
||||
h = height - 2 * spaceAround;
|
||||
} else {
|
||||
x = spaceAround + marginLeft;
|
||||
y = spaceAround + currentGrid * (length + spaceBetween) + marginTop + offsetY;
|
||||
w = width - 2 * spaceAround;
|
||||
h = myLength;
|
||||
}
|
||||
currentGrid += item.size || 1;
|
||||
buttons.push(createButton(x, y, w, h, item.text, item.callback));
|
||||
})
|
||||
|
||||
function render() {
|
||||
buttons.forEach(drawButton);
|
||||
if (options.title) {
|
||||
g.setFont(titleFont)
|
||||
.setFontAlign(0, -1)
|
||||
.drawString(options.title, width / 2 + marginLeft, offsetY);
|
||||
}
|
||||
swipeControls.forEach(control => control.draw());
|
||||
}
|
||||
|
||||
const touchFunc = (button, xy) => buttons.forEach(b => b.onTouch && b.onTouch(button, xy));
|
||||
const swipeFunc = (LR, UD) => swipeControls.forEach(s => s.onSwipe(LR, UD));
|
||||
const btnFunc = options.backFn;
|
||||
return {
|
||||
buttons, render, setUI: () => Bangle.setUI({mode: "custom", touch: touchFunc, swipe: swipeFunc, btn: btnFunc})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a menu object (a custom menu object created in this app, not an Espruino menu object) draw the menu to the
|
||||
* screen and set the UI framework to the one appropriate to that menu.
|
||||
* @param menu
|
||||
*/
|
||||
function setMenu(menu) {
|
||||
save();
|
||||
g.reset();
|
||||
g.clearRect(Bangle.appRect);
|
||||
currentMenu = menu;
|
||||
menu.render();
|
||||
menu.setUI();
|
||||
Bangle.drawWidgets();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new task with a given set of initial text. The user will be prompted with a keyboard to title the task.
|
||||
* Once the task is created, begin that task.
|
||||
* @param initialText
|
||||
*/
|
||||
function newTask(initialText) {
|
||||
nudgeManager.interrupt();
|
||||
initialText = initialText || "";
|
||||
textInput.input({text: initialText, keyboardMain: keyboardAlpha, keyboardShift: keyboardAlphaShift})
|
||||
.then(text => {
|
||||
if (!text) {
|
||||
setMenu(mainMenu);
|
||||
}
|
||||
const task = createTask(text)
|
||||
allTasks.unshift(task);
|
||||
save();
|
||||
startTask(task);
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin the indicated task, taking the user to the corresponding menu / display screen and starting all relevant timers
|
||||
* @param task
|
||||
*/
|
||||
function startTask(task) {
|
||||
nudgeManager.queueNudge(task, () => nudge(task));
|
||||
g.clear();
|
||||
const onPressBack = () => {
|
||||
nudgeManager.interrupt();
|
||||
setMenu(mainMenu)
|
||||
}
|
||||
setMenu(getTaskMenu(task, onPressBack));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remind the user of an ongoing task, prompting them to affirm that they are on task, distracted, or, after a set time
|
||||
* period, unresponsive.
|
||||
* @param task
|
||||
*/
|
||||
function nudge(task) {
|
||||
Bangle.buzz(250, 1)
|
||||
.then(() => {
|
||||
Bangle.setLocked(false);
|
||||
Bangle.setLCDPower(true);
|
||||
});
|
||||
const nudgeMenu = createMenu({
|
||||
title : "Are you on task?", titleFont: "6x8", items: [
|
||||
{text: task.text, size: 1}, {text: "On Task", size: 1, callback: () => affirmOnTask(task)}, {
|
||||
text: "Distracted", size: 1, callback: () => affirmDistracted(task)
|
||||
}
|
||||
], isHorizontal: false
|
||||
});
|
||||
setMenu(nudgeMenu);
|
||||
nudgeManager.queueResponseTimeout(() => concludeUnresponsive(task));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoked when the user affirms that they are on task, increasing the affirmation count on the given task and
|
||||
* advancing the incremental backoff counter. Congratulates the user for the response.
|
||||
* @param task
|
||||
*/
|
||||
function affirmOnTask(task) {
|
||||
task.affirmCount++;
|
||||
task.backoffIndex = Math.min(task.incrementalBackoffSet.length - 1, task.backoffIndex + 1);
|
||||
showTempMessage("Great job!", "On Task!", () => startTask(task));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoked when the user affirms that they were distracted, increasing the distraction count and lowering the
|
||||
* incremental backoff counter. Encourages the user to keep trying.
|
||||
* @param task
|
||||
*/
|
||||
function affirmDistracted(task) {
|
||||
task.distractCount++;
|
||||
task.backoffIndex = Math.max(0, task.backoffIndex - 1);
|
||||
showTempMessage("Don't worry! You've got this!", "Distracted!", () => startTask(task));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoked when the user has not responded to an "on task?" prompt. Increments the unresponsive count and decrements
|
||||
* the incremental backoff counter.
|
||||
* @param task
|
||||
*/
|
||||
function concludeUnresponsive(task) {
|
||||
Bangle.buzz(250, 1)
|
||||
.then(() => Bangle.setLCDPower(true));
|
||||
task.unresponsiveCount++;
|
||||
task.backoffIndex = Math.max(0, task.backoffIndex - 1);
|
||||
nudgeManager.queueResponseTimeout(() => concludeUnresponsive(task))
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the user a message for a short period of time, then calls the "then function"
|
||||
* @param text
|
||||
* @param title
|
||||
* @param thenFn
|
||||
*/
|
||||
function showTempMessage(text, title, thenFn) {
|
||||
E.showMessage(text, {title});
|
||||
setTimeout(() => {
|
||||
thenFn();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the task as completed and then push it to the bottom of the list.
|
||||
* @param task
|
||||
*/
|
||||
function completeTask(task) {
|
||||
nudgeManager.interrupt();
|
||||
task.complete = true;
|
||||
removeTask(task, allTasks);
|
||||
allTasks.push(task);
|
||||
save();
|
||||
setMenu(getTaskMenu(task));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the task as not completed and then push it to the top of the list.
|
||||
* @param task
|
||||
*/
|
||||
function restartTask(task) {
|
||||
task.complete = false;
|
||||
removeTask(task, allTasks);
|
||||
allTasks.unshift(task);
|
||||
save();
|
||||
startTask(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the task from the given list.
|
||||
* @param task
|
||||
* @param list
|
||||
*/
|
||||
function removeTask(task, list) {
|
||||
const taskIndex = list.findIndex((item) => item === task);
|
||||
if (taskIndex !== -1) {
|
||||
list.splice(taskIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a task object given a set of text.
|
||||
* @param text
|
||||
* @returns {{distractCount: number, backoffIndex: number, incrementalBackoffSet: number[], affirmCount: number,
|
||||
* unresponsiveCount: number, interval: number, text, complete: boolean, useBackoff: boolean}}
|
||||
*/
|
||||
function createTask(text) {
|
||||
const incrementalBackoffSet = [0.5, 1, 2, 4, 8, 16, 32];
|
||||
return {
|
||||
text,
|
||||
affirmCount : 0,
|
||||
distractCount : 0,
|
||||
unresponsiveCount: 0,
|
||||
interval : 30,
|
||||
backoffIndex : 1,
|
||||
incrementalBackoffSet,
|
||||
complete : false,
|
||||
useBackoff : true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a menu for editing the various properties of a given task. Also exposes the functions to start, restart, or
|
||||
* delete the given task.
|
||||
* @param task
|
||||
* @param backFn
|
||||
*/
|
||||
function editTask(task, backFn) {
|
||||
nudgeManager.interrupt();
|
||||
let editMenu = [];
|
||||
if (task.complete) {
|
||||
editMenu.push({title: "Start Task", onchange: () => restartTask(task)});
|
||||
editMenu.push({title: "View Task", onchange: () => startTask(task)});
|
||||
} else {
|
||||
editMenu.push({title: "Resume Task", onchange: () => startTask(task)});
|
||||
}
|
||||
editMenu.push({title: "Rename", onchange: () => renameTask(task, () => editTask(task, backFn))});
|
||||
editMenu.push({title: "Interval", value: task.interval, min: 10, step: 10, onchange: v => task.interval = v});
|
||||
editMenu.push({title: "Incremental Backoff", value: !!task.useBackoff, onchange: v => task.useBackoff = v});
|
||||
editMenu.push({title: "DELETE", onchange: () => deleteTask(task, () => editTask(task, backFn), backFn)});
|
||||
editMenu.push({title: "Statistics:"});
|
||||
editMenu.push({title: "On Task: " + task.affirmCount});
|
||||
editMenu.push({title: "Distracted: " + task.distractCount});
|
||||
editMenu.push({title: "Unresponsive: " + task.unresponsiveCount});
|
||||
editMenu[""] = {title: task.text, back: backFn};
|
||||
E.showMenu(editMenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given task from the task list permanently if the user hits "yes" on the confirmation dialogue.
|
||||
* @param task The task to delete.
|
||||
* @param backFn The function to be called when the user cancels.
|
||||
* @param deleteBackFn The function to be called when the user confirms.
|
||||
*/
|
||||
function deleteTask(task, backFn, deleteBackFn) {
|
||||
E.showPrompt("Delete " + task.text + "?")
|
||||
.then(shouldDelete => {
|
||||
if (shouldDelete) {
|
||||
removeTask(task, allTasks);
|
||||
deleteBackFn();
|
||||
} else {
|
||||
backFn();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the text of the given task, and then execute the given function.
|
||||
* @param task
|
||||
* @param backFn The function to execute after the renaming. Typically to show some previous menu.
|
||||
* @returns {*}
|
||||
*/
|
||||
function renameTask(task, backFn) {
|
||||
return textInput.input({text: task.text, keyboardMain: keyboardAlpha, keyboardShift: keyboardAlphaShift})
|
||||
.then(text => {
|
||||
task.text = text
|
||||
save();
|
||||
backFn();
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the "menu" that displays a given active task. This may not seem like a menu to users, but it includes swipe
|
||||
* controls and can sometimes include pressable buttons as well.
|
||||
* @param task
|
||||
* @param backFn
|
||||
* @returns {{setUI: (function(): void), buttons: *[], render: render}}
|
||||
*/
|
||||
function getTaskMenu(task, backFn) {
|
||||
const d = new Date();
|
||||
const h = d.getHours(), m = d.getMinutes();
|
||||
const time = h + ":" + m.toString()
|
||||
.padStart(2, 0);
|
||||
const taskSwipeControls = [
|
||||
createSwipeControl(SWIPE.LEFT, "Menu", () => {
|
||||
setMenu(mainMenu);
|
||||
nudgeManager.interrupt();
|
||||
}), createSwipeControl(SWIPE.RIGHT, "New Task", newTask),
|
||||
];
|
||||
const items = [];
|
||||
if (task.complete) {
|
||||
taskSwipeControls.push(createSwipeControl(SWIPE.UP, "Restart", () => restartTask(task)));
|
||||
taskSwipeControls.push(createSwipeControl(SWIPE.DOWN,
|
||||
"Task List",
|
||||
() => showTaskList(() => true, () => startTask(task))));
|
||||
items.push({text: task.text + " completed!", size: 1});
|
||||
const nextTask = getNextTask(task, allTasks);
|
||||
if (nextTask) {
|
||||
items.push({text: "next task: " + nextTask.text, size: 2, callback: () => startTask(nextTask)})
|
||||
} else {
|
||||
items.push({
|
||||
text: "Affirmed: " + task.affirmCount + "\nDistracted: " + task.distractCount + "\nUnresponsive: " + task.unresponsiveCount,
|
||||
size: 3
|
||||
});
|
||||
}
|
||||
} else {
|
||||
items.push({text: task.text, size: 2})
|
||||
taskSwipeControls.push(createSwipeControl(SWIPE.UP, "Complete", () => completeTask(task)))
|
||||
taskSwipeControls.push(createSwipeControl(SWIPE.DOWN, "Edit Task", () => editTask(task, () => startTask(task))))
|
||||
}
|
||||
return createMenu({
|
||||
items, spaceAround: 0, spaceBetween: 0, swipeControls: taskSwipeControls, title: time, backFn
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a task, determine the next incomplete task in the task list and return it. Return undefined if there are no
|
||||
* other incomplete tasks.
|
||||
* @param task
|
||||
* @param list
|
||||
* @returns {undefined|*}
|
||||
*/
|
||||
function getNextTask(task, list) {
|
||||
const activeList = list.filter(x => (!x.complete || x === task));
|
||||
const thisTaskPosition = activeList.findIndex(t => t === task);
|
||||
let nextTask = activeList[0];
|
||||
if (thisTaskPosition !== -1 && activeList[thisTaskPosition + 1]) {
|
||||
nextTask = activeList[thisTaskPosition + 1];
|
||||
}
|
||||
return nextTask === task ? undefined : nextTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the list of tasks in a menu, filtered by the filterFn. Selecting a task in this menu will bring you to that
|
||||
* task's edit menu.
|
||||
* @param filterFn
|
||||
* @param backFn
|
||||
*/
|
||||
function showTaskList(filterFn, backFn) {
|
||||
let taskMenu = [];
|
||||
const list = allTasks.filter(filterFn);
|
||||
taskMenu = taskMenu.concat(list.map(task => {
|
||||
return {
|
||||
// Workaround - navigation has phantom buttons rendered with E.showMenu unless you delay slightly.
|
||||
title: task.text, onchange: () => editTask(task, () => showTaskList(filterFn, backFn))
|
||||
}
|
||||
}))
|
||||
taskMenu[""] = {title: "Tasks", back: backFn};
|
||||
E.showMenu(taskMenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the menu for editing settings and tasks.
|
||||
* @param backFn
|
||||
*/
|
||||
function showSettingsMenu(backFn) {
|
||||
const settingsMenu = {
|
||||
"" : {title: "Manage", back: backFn},
|
||||
"Pending Tasks" : () => showTaskList(task => !task.complete, () => showSettingsMenu(backFn)),
|
||||
"Completed Tasks": () => showTaskList(task => task.complete, () => showSettingsMenu(backFn)),
|
||||
"Text Outlines" : {value: savedData.settings.textOutlines, onchange: v => savedData.settings.textOutlines = v},
|
||||
"No Word Breaks" : {value: savedData.settings.noWordBreaks, onchange: v => savedData.settings.noWordBreaks = v}
|
||||
}
|
||||
E.showMenu(settingsMenu);
|
||||
}
|
||||
const mainMenu = createMenu({
|
||||
title : "Working Memory", items: [
|
||||
{text: "New Task", size: 2, callback: () => newTask("")}, {
|
||||
text: "Manage", size: 1, callback: () => showSettingsMenu(() => setMenu(mainMenu))
|
||||
}
|
||||
], isHorizontal: false
|
||||
});
|
||||
|
||||
setMenu(mainMenu);
|
After Width: | Height: | Size: 1.2 KiB |
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"id" : "wrkmem",
|
||||
"name" : "Working Memory Helper",
|
||||
"shortName" : "Work Mem",
|
||||
"version" : "1.00",
|
||||
"description" : "Externalize your working memory to help stay on task.",
|
||||
"dependencies" : {"textinput": "type"},
|
||||
"icon" : "icon.png",
|
||||
"type" : "app",
|
||||
"tags" : "tool",
|
||||
"supports" : ["BANGLEJS2"],
|
||||
"screenshots" : [
|
||||
{"url": "screenshot.png"},
|
||||
{"url": "screenshot2.png"},
|
||||
{"url": "screenshot3.png"},
|
||||
{"url": "screenshot4.png"},
|
||||
{"url": "screenshot5.png"},
|
||||
{"url": "screenshot6.png"}
|
||||
],
|
||||
"readme" : "README.md",
|
||||
"allow_emulator": false,
|
||||
"storage" : [
|
||||
{"name": "wrkmem.app.js", "url": "app.js"},
|
||||
{"name": "wrkmem.img", "url": "app-icon.js", "evaluate": true}
|
||||
],
|
||||
"data" : [{"name": "wrkmem.json"}]
|
||||
}
|
After Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 3.5 KiB |
After Width: | Height: | Size: 2.6 KiB |
After Width: | Height: | Size: 2.9 KiB |