BangleApps/apps/messagelist/app.js

1209 lines
43 KiB
JavaScript

/* MESSAGES is a list of:
{id:int,
src,
title,
subject,
body,
sender,
tel:string,
new:true // not read yet
}
*/
/* For example for maps:
// a message
{"t":"add","id":1575479849,"src":"Hangouts","title":"A Name","body":"message contents"}
// maps
{"t":"add","id":1,"src":"Maps","title":"0 yd - High St","body":"Campton - 11:48 ETA","img":"GhqBAAAMAAAHgAAD8AAB/gAA/8AAf/gAP/8AH//gD/98B//Pg/4B8f8Afv+PP//n3/f5//j+f/wfn/4D5/8Aef+AD//AAf/gAD/wAAf4AAD8AAAeAAADAAA="}
// call
{"t":"add","id":"call","src":"Phone","name":"Bob","number":"12421312",positive:true,negative:true}
*/
{
const B2 = process.env.HWVERSION>1, // Bangle.js 2?
RIGHT = 1, LEFT = -1, // swipe directions
UP = -1, DOWN = 1; // updown directions
const Layout = require("Layout");
const settings = () => require("messagegui").settings();
const fontTiny = "6x8"; // fixed size, don't use this for important things
let fontNormal;
// setFont() is also called after we close the settings screen
const setFont = function() {
const fontSize = settings().fontSize;
if (fontSize===0) // small
fontNormal = g.getFonts().includes("6x15") ? "6x15" : "6x8:2";
else if (fontSize===2) // large
fontNormal = g.getFonts().includes("6x15") ? "6x15:2" : "6x8:4";
else // medium
fontNormal = g.getFonts().includes("12x20") ? "12x20" : "6x8:3";
};
setFont();
let active, back; // active screen, last active screen
/// List of all our messages
let MESSAGES;
const saveMessages = function() {
const noSave = ["alarm", "call", "music"]; // assume these are outdated once we close the app
noSave.forEach(id => remove({id: id}));
require("messages").write(MESSAGES
.filter(m => m.id && !noSave.includes(m.id))
.map(m => {
delete m.show;
return m;
})
);
};
const uiRemove = function() {
if (musicTimeout) clearTimeout(musicTimeout);
layout = undefined;
Bangle.removeListener("message", onMessage);
saveMessages();
clearUnreadStuff();
delete Bangle.appRect;
};
const quitApp = () => load(); // TODO: revert to Bangle.showClock after fixing memory leaks
try {
MESSAGES = require("messages").getMessages();
// Apply fast loaded messages
(Bangle.MESSAGES || []).forEach(m => require("messages").apply(m, MESSAGES));
delete Bangle.MESSAGES;
// Write them back to storage when we're done
E.on("kill", saveMessages);
} catch(e) {
g.reset().clear();
E.showPrompt(/*LANG*/"Message file corrupt, erase all messages?", {title:/*LANG*/"Delete All Messages"}).then(isYes => {
// We are troubleshooting, so do a clean "load" in both cases (instead of Bangle.load)
if (isYes) { // OK: erase message file and reload this app
require("messages").clearAll();
load("messagelist.app.js");
} else {
load(); // well, this app won't work... let's go back to the clock
}
});
}
const setUI = function(options, cb) {
delete Bangle.uiRemove; // don't clear out things when switching UI within the app
options = Object.assign({remove: () => uiRemove()}, options);
Bangle.setUI(options, cb);
};
const remove = function(msg) {
if (msg.id==="call") call = undefined;
else if (msg.id==="map") map = undefined;
else if (msg.id==="alarm") alarm = undefined;
else if (msg.id==="music") music = undefined;
else MESSAGES = MESSAGES.filter(m => m.id!==msg.id);
};
const buzz = function(msg) {
return require("messages").buzz(msg.src);
};
const show = function(msg) {
delete msg.show; // don't show this again
if (msg.id==="call") showCall(msg);
else if (msg.id==="map") showMap(msg);
else if (msg.id==="alarm") showAlarm(msg);
else if (msg.id==="music") showMusic(msg);
else showMessage(msg);
};
const onMessage = function(type, msg) {
if (msg.handled) return;
msg.handled = true;
switch(type) {
case "call":
return onCall(msg);
case "music":
return onMusic(msg);
case "map":
return onMap(msg);
case "alarm":
return onAlarm(msg);
case "text":
return onText(msg);
case "clearAll":
MESSAGES = [];
if (["messages", "menu"].includes(active)) showMenu();
break;
default:
E.showAlert(/*LANG*/"Unknown message type:"+"\n"+type).then(goBack);
}
};
Bangle.on("message", onMessage);
const onCall = function(msg) {
if (msg.t==="remove") {
call = undefined;
return exitScreen("call");
}
// incoming call: show it
call = msg;
buzz(call);
showCall();
};
const onAlarm = function(msg) {
if (msg.t==="remove") {
alarm = undefined;
return exitScreen("alarm");
}
alarm = msg;
buzz(alarm);
showAlarm();
};
let musicTimeout;
const onMusic = function(msg) {
const hadMusic = !!music;
if (musicTimeout) clearTimeout(musicTimeout);
musicTimeout = undefined;
if (msg.t==="remove") {
music = undefined;
if (active==="main" && hadMusic) return showMain(); // refresh menu: remove "Music" entry (if not always visible)
else return exitScreen("music");
}
music = Object.assign({}, music, msg);
// auto-close after being paused
if (music.state!=="play") musicTimeout = setTimeout(function() {
musicTimeout = undefined;
if (active==="music" && (!music || music.state!=="play")) quitApp();
}, 60*1000); // paused for 1 minute
// auto-close after "playing" way beyond song duration (because "stop" messages don't seem to exist)
else musicTimeout = setTimeout(function() {
musicTimeout = undefined;
if (active==="music" && (!music || music.state==="play")) quitApp();
}, 2*Math.max(music.dur || 0, 5*60)*1000); // playing: assume ended after twice song duration, or at least 10 minutes
if (active==="music") showMusic(); // update music screen
else if (active==="main" && !hadMusic) {
if (settings().openMusic && music.state==="play" && music.track) showMusic();
else showMain(); // refresh menu: add "Music" entry
}
};
const onMap = function(msg) {
const hadMap = !!map;
if (msg.t==="remove") {
map = undefined;
if (back==="map") back = undefined;
if (active==="main" && hadMap) return showMain(); // refresh menu: remove "Map" entry
else return exitScreen("map");
}
map = msg;
if (["map", "music"].includes(active)) showMap(); // update map screen, or switch away from music (not other screens)
else if (active==="main" && !hadMap) showMain(); // refresh menu: add "Map" entry
};
const onText = function(msg) {
require("messages").apply(msg, MESSAGES);
const mIdx = MESSAGES.findIndex(m => m.id===msg.id);
if (!MESSAGES[mIdx]) if (back==="messages") back = undefined;
if (active==="main") showMain(); // update message count
if (MESSAGES.length===0) exitScreen("messages"); // removed last message
else if (active==="messages") showMessage(messageNum);
if (msg.new) buzz(msg);
if (active!=="call") {// don't switch away from incoming call
if (active!=="messages" || messageNum===mIdx) showMessage(mIdx);
}
if (active==="messages") drawFooter(); // update footer with new number of messages
};
const getImage = function(msg, def) {
// app icons, provided by `messages` app
return require("messageicons").getImage(msg);
};
const getImageColor = function(msg, def) {
// app colors, provided by `messages` app
return require("messageicons").getColor(msg, {default: def});
};
const getIcon = function(icon) {
return require("messagegui").getIcon(icon);
};
const getIconColor = function(icon) {
return require("messagegui").getColor(icon);
};
/*
* icons should be 24x24px with 1bpp colors and transparancy
*/
const getMessageImage = function(msg) {
if (msg.img) return atob(msg.img);
if (msg.id==="music") return getIcon("Music");
if (msg.id==="back") return getIcon("Back");
const s = (msg.src || "").toLowerCase();
return getImage(s, "notification");
};
const showMap = function() {
setActive("map");
delete map.new;
let m, distance, street, target, eta;
m = map.title.match(/(.*) - (.*)/);
if (m) {
distance = m[1];
street = m[2];
} else {
street = map.title;
}
m = map.body.match(/(.*) - (.*)/);
if (m) {
target = m[1];
eta = m[2];
} else {
target = map.body;
}
let layout = new Layout({
type: "v", c: [
{type: "txt", font: fontNormal, label: target, bgCol: g.theme.bg2, col: g.theme.fg2, fillx: 1, pad: 2},
{
type: "h", bgCol: g.theme.bg2, col: g.theme.fg2, fillx: 1, c: [
{type: "txt", font: "6x8", label: "Towards"},
{type: "txt", font: fontNormal, label: street},
]
},
{
type: "h", fillx: 1, filly: 1, c: [
map.img ? {type: "img", src: () => atob(map.img), scale: 2} : {},
{
type: "v", fillx: 1, c: [
{type: "txt", font: fontNormal, label: distance || ""},
]
},
]
},
{type: "txt", font: "6x8:2", label: eta}
]
});
layout.render();
// go back on any input
setUI({
mode: "custom",
back: goBack,
btn: b => {
if (B2 || b===2) goBack();
},
swipe: dir => {
if (dir===RIGHT) showMain();
},
});
};
const toggleMusic = function() {
const mc = cmd => {
if (Bangle.musicControl) Bangle.musicControl(cmd);
};
if (!music) {
music = {state: "play"};
mc("play");
} else if (music.state==="play") {
music.state = "pause";
mc("pause");
} else {
music.state = "play";
mc("play");
}
if (layout && layout.musicIcon) {
// musicIcon/musicToggle .src returns icon based on current music.state
layout.update(layout.musicIcon);
if (layout.musicToggle) layout.update(layout.musicToggle);
layout.render();
}
};
const doMusic = function(action) {
if (!Bangle.musicControl) return;
Bangle.buzz(50);
if (action==="toggle") toggleMusic();
else Bangle.musicControl(action);
};
const showMusic = function() {
if (active!==music) setActive("music");
if (!music) music = {track: "<unknown>", artist: "<unknown>", album: "", state: "pause"};
delete music.new;
const w = Bangle.appRect.w-50; // title/album need to leave room for icon
let artist, album;
if (music.album && settings().showAlbum) {
// max 2 lines for artist/album
artist = g.setFont(fontNormal).wrapString(music.artist, w).slice(0, 2).join("\n");
album = g.wrapString(music.album, w).slice(0, 2).join("\n");
} else {
// no album: artist gets 3 lines
artist = g.setFont(fontNormal).wrapString(music.artist, w).slice(0, 3).join("\n");
album = "";
}
// place (subtitle) on a new line
let track = music.track.replace(/ \(/, "\n(");
track = g.wrapString(track, Bangle.appRect.w).slice(0, 5).join("\n");
// "unknown" n/c/dur can show up as -1
let num, dur;
if ("n" in music && music.n>0) {
num = "#"+music.n;
if ("c" in music && music.c>0) {
num += "/"+music.c;
}
num = {type: "txt", font: fontTiny, bgCol: g.theme.bg, label: num};
}
if ("dur" in music && music.dur>0) {
dur = Math.floor(music.dur/60)+":"+(music.dur%60).toString().padStart(2, "0");
dur = {type: "txt", font: fontTiny, bgCol: g.theme.bg, label: dur};
}
let info;
if (num && dur) info = {type: "h", fillx: 1, c: [{fillx: 1}, dur, {fillx: 1}, num, {fillx: 1},]};
else if (num) info = num;
else if (dur) info = dur;
else info = {};
layout = new Layout({
type: "v", c: [
{
type: "h", fillx: 1, bgCol: g.theme.bg2, col: g.theme.fg2, c: [
{
id: "musicIcon", type: "img", pad: 10, bgCol: g.theme.bg2, col: g.theme.fg2
, src: () => getIcon((music.state==="play") ? "music" : "pause")
},
{
type: "v", fillx: 1, c: [
{type: "txt", font: fontNormal, col: g.theme.fg2, bgCol: g.theme.bg2, label: artist, pad: 2, id: "artist"},
album ? {type: "txt", font: fontNormal, col: g.theme.fg2, bgCol: g.theme.bg2, label: album, pad: 2, id: "album"} : {},
]
}
]
},
{type: "txt", halign: 0, font: fontNormal, bgCol: g.theme.bg, label: track, fillx: 1, filly: 1, pad: 2, id: "track"},
settings().musicButtons ? {
type: "h", fillx: 1, c: [
B2 ? {} : {width: 4},
{
type: "btn", id: "previous", cb: () => doMusic("previous")
, src: () => getIcon("previous")
},
{fillx: 1},
{
type: "btn", id: "musicToggle", cb: () => doMusic("toggle")
, src: () => getIcon((music.state==="play") ? "pause" : "play")
},
{fillx: 1},
{
type: "btn", id: "next", cb: () => doMusic("next")
, src: () => getIcon("next")
},
B2 ? {} : {width: 4},
]
} : {},
info,
]
});
layout.render();
let options = {mode: "updown"};
// B1 with buttons: left hand side of screen is used for "previous"
if (B2 || !settings().musicButtons) options.back = goBack;
setUI(options, ud => {
if (ud) Bangle.musicControl(ud>0 ? "volumedown" : "volumeup");
else {
if (B2 || settings().musicButtons) goBack(); // B1 left-hand touch is "previous", so we need a way to go back
else doMusic("toggle");
}
});
Bangle.swipeHandler = dir => {
if (dir!==0) doMusic(dir===RIGHT ? "previous" : "next");
};
Bangle.on("swipe", Bangle.swipeHandler);
if (Bangle.touchHandler) Bangle.removeListener("touch", Bangle.touchHandler);
if (settings().musicButtons) {
// visible buttons
// left = previous, middle = toggle, right = next
if (B2) Bangle.touchHandler = (_side, xy) => {
// accept touches on the whole bottom and pick the closest button
if (xy.y<Bangle.appRect.y+Bangle.appRect.h/2) return; // ignore touches top half
if (xy.x<Bangle.appRect.w/3) doMusic("previous");
else if (xy.x>2*Bangle.appRect.w/3) doMusic("next");
else doMusic("toggle");
};
else Bangle.touchHandler = (side) => {
if (side===1) doMusic("previous");
if (side===2) doMusic("next");
if (side===3) doMusic("toggle");
};
} else {
// no buttons: touch = toggle
// B2 setUI sets touchHandler, override that (we only want up/down swipes from the UI)
Bangle.touchHandler = (side, e) => {
// B1: side 1 (left) = back, B2: only toggle for e outside widget area
if ((!B2 && side>1) || (B2 && e.y>Bangle.appRect.y)) doMusic("toggle");
};
}
Bangle.on("touch", Bangle.touchHandler);
};
let layout;
const clearStuff = function() {
delete Bangle.appRect;
layout = undefined;
setUI();
g.reset().clearRect(Bangle.appRect);
};
const setActive = function(screen, args) {
clearStuff();
if (active && screen!==active) back = active;
if (screen==="messages") messageNum = args;
active = screen;
};
/**
* Go back to previous screen, preserving history
*/
const goBack = function() {
if (back==="call" && call) showCall();
else if (back==="map" && map) showMap();
else if (back==="music" && music) showMusic();
else if (back==="messages" && MESSAGES.length) showMessage();
else if (back) showMain(); // previous screen was "main", or no longer valid
else quitApp(); // no previous screen: go back to clock
};
/**
* Leave screen, and make sure goBack() won't take us there anymore;
* @param {string} screen
*/
const exitScreen = function(screen) {
if (back===screen) back = (active==="main") ? undefined : "main";
if (active===screen) {
active = undefined;
goBack();
}
};
const showMain = function() {
setActive("main");
let grid = {"": {title:/*LANG*/"Messages", align: 0, back: load}};
if (call) grid[/*LANG*/"Incoming Call"] = {icon: "Phone", cb: showCall};
if (alarm) grid[/*LANG*/"Alarm"] = {icon: "Alarm", cb: showAlarm};
const unread = MESSAGES.filter(m => m.new).length;
if (unread) {
grid[unread+" "+/*LANG*/"New"] = {icon: "Unread", cb: () => showMessage(MESSAGES.findIndex(m => m.new))};
grid[/*LANG*/"All"+` (${MESSAGES.length})`] = {icon: "Notification", cb: showMessage};
} else {
const allLabel = MESSAGES.length+" "+(MESSAGES.length===1 ?/*LANG*/"Message" :/*LANG*/"Messages");
if (MESSAGES.length) grid[allLabel] = {icon: "Notification", cb: showMessage};
else grid[/*LANG*/"No Messages"] = {icon: "Neg", cb: load};
}
if (unread<MESSAGES.length) {
grid[/*LANG*/"Dismiss Read"] = {
icon: "Trash", cb: () => {
E.showPrompt(/*LANG*/"Are you sure?", {title:/*LANG*/"Dismiss Read Messages"}).then(isYes => {
if (isYes) {
MESSAGES.filter(m => !m.new).forEach(msg => {
Bangle.messageResponse(msg, false);
remove(msg);
});
}
showMain();
});
}
};
}
if (map) grid[/*LANG*/"Map"] = {icon: "Map", cb: showMap};
if (music || settings().alwaysShowMusic) grid[/*LANG*/"Music"] = {icon: "Music", cb: showMusic};
grid[/*LANG*/"settings"] = {icon: "settings", cb: showSettings};
showGrid(grid);
};
const clamp = function(val, min, max) {
if (val<min) return min;
if (val>max) return max;
return val;
};
/**
* Show grid of labeled buttons,
*
* items:
* {
* cb: callback,
* img: button image,
* icon: icon name, // string, use getIcon(icon) instead of img
* col: icon color, // optional: defaults to getColor(icon)
* }
* "" item is options:
* {
* title: string,
* back: callback,
* rows/cols: (optional) fit to this many columns/rows, omit for automatic fit
* align: bottom row alignment if items don't fit perfectly into a grid
* -1: left
* 1: right
* 0: left, but move final button to the right
* undefined: spread (can be unaligned with rest of grid!)
* }
* @param items
*/
const showGrid = function(items) {
clearStuff();
const options = items[""] || {},
back = options.back || items["< Back"];
const keys = Object.keys(items).filter(k => k!=="" && k!=="< Back");
let cols;
if (options.cols) {
cols = options.cols;
} else if (options.rows) {
cols = Math.ceil(keys.length/options.rows);
} else {
const rows = Math.round(Math.sqrt(keys.length));
cols = Math.ceil(keys.length/rows);
}
let l = {type: "v", c: []};
if (options.title) {
l.c.push({id: "title", type: "txt", label: options.title, font: (B2 ? "12x20" : "6x8:2"), fillx: 1});
}
const w = Bangle.appRect.w/cols, // set explicit width, because labels can stick out
bgs = [g.theme.bgH, g.theme.bg2], // background colors used for buttons
newRow = () => ({type: "h", filly: 1, c: []});
let row = newRow(),
cbs = [[]]; // callbacks for Bangle.js 2 touchHandler below
keys.forEach(key => {
const item = items[key],
label = g.setFont(fontTiny).wrapString(key, w).join("\n");
let color = "col" in item ? item.col : getIconColor(item.icon || "Unknown");
if (color && bgs.includes(g.setColor(color).getColor())) color = undefined; // make sure button is not invisible
row.c.push({
type: "v", pad: 2, width: w, c: [
{
type: "btn",
src: item.img || (() => getIcon(item.icon || "Unknown")),
col: color,
cb: B2
? undefined // We handle B2 touches below
: () => setTimeout(item.cb), // prevent MEMORY error from running cb() inside the Layout touchHandler
},
{height: 2},
{type: "txt", label: label, font: fontTiny},
]
});
if (B2) cbs[cbs.length-1].push(item.cb);
if (row.c.length>=cols) {
l.c.push(row);
row = newRow();
if (B2) cbs.push([]);
}
});
if (row.c.length) {
if (options.align!==undefined) {
const filler = {width: w*(cols-row.c.length)};
if (options.align=== -1) row.c.unshift(filler); // left
else if (options.align===1) row.c.push(filler); // right
else if (options.align===0) row.c.splice(row.c.length-1, 0, filler); // left, but final item on right
}
l.c.push(row);
}
layout = new Layout(l, {back: back});
layout.render();
if (B2) {
// override touchHandler: no need to hit buttons exactly, just pick the nearest
if (Bangle.touchHandler) Bangle.removeListener("touch", Bangle.touchHandler);
Bangle.touchHandler = (side, xy) => {
if (xy.y<=Bangle.appRect.y) return; // widgetbar: ignore
let rows = l.c.length,
y = Bangle.appRect.y, h = Bangle.appRect.h;
if (options.title) {
rows--;
y += layout.title.h;
h -= layout.title.h;
}
const r = clamp(Math.floor(rows*(xy.y-y)/h), 0, rows-1); // row (0-indexed)
let c; // column (0-indexed)
if (r<rows-1) {
// regular row
c = clamp(Math.floor(cols*(xy.x-Bangle.appRect.x)/Bangle.appRect.w), 0, cols-1);
} else {
// bottom row: layout depends on align
if (options.align=== -1) { // left
c = clamp(Math.floor(cols*(xy.x-Bangle.appRect.x)/Bangle.appRect.w), 0, cols-1);
} else if (options.align===1) { // right
c = clamp(Math.floor(cols*(xy.x-Bangle.appRect.x)/Bangle.appRect.w)+(cols-cbs[r].length), 0, cols-1);
} else if (options.align===0) { // left, but with final item on right
c = clamp(Math.floor(cols*(xy.x-Bangle.appRect.x)/Bangle.appRect.w), 0, cols-1);
if (c===cols-1) c = cbs[r].length-1; // final row=last item
else if (c>cbs[r].length-2) return; // gap before final item
} else { // spread
c = clamp(Math.floor(cbs[r].length*(xy.x-Bangle.appRect.x)/Bangle.appRect.w), 0, cols-1);
}
}
if (r<cbs.length && c<cbs[r].length) {// should always be true
Bangle.buzz(50);
cbs[r][c]();
}
};
Bangle.on("touch", Bangle.touchHandler);
}
};
const showSettings = function() {
setActive("settings");
eval(require("Storage").read("messagelist.settings.js"))(() => {
setFont();
showMain();
});
};
const showCall = function() {
setActive("call");
delete call.new;
Bangle.setLocked(false);
Bangle.setLCDPower(1);
const w = g.getWidth()-48,
lines = g.setFont(fontNormal).wrapString(call.title, w),
title = (lines.length>2) ? lines.slice(0, 2).join("\n")+"..." : lines.join("\n");
const respond = function(accept) {
Bangle.buzz(50);
Bangle.messageResponse(call, accept);
remove(call);
call = undefined;
goBack();
};
let options = {};
if (!B2) {
options.btns = [
{
label:/*LANG*/"accept",
cb: () => respond(true),
}, {
label:/*LANG*/"ignore",
cb: goBack,
}, {
label:/*LANG*/"reject",
cb: () => respond(false),
}
];
}
layout = new Layout({
type: "v", c: [
{
type: "h", fillx: 1, bgCol: g.theme.bg2, col: g.theme.fg2, c: [
{type: "img", pad: 10, src: () => getIcon("phone"), col: getIconColor("phone")},
{
type: "v", fillx: 1, c: [
{type: "txt", font: fontTiny, label: call.src ||/*LANG*/"Incoming Call", bgCol: g.theme.bg2, col: g.theme.fg2, fillx: 1, pad: 2, halign: 1},
title ? {type: "txt", font: fontNormal, label: title, bgCol: g.theme.bg2, col: g.theme.fg2, fillx: 1, pad: 2} : {},
]
},
]
},
{type: "txt", font: fontNormal, label: call.body, fillx: 1, filly: 1, pad: 2, wrap: true},
{
type: "h", fillx: 1, c: [
// button callbacks won't actually be used: setUI below overrides the touchHandler set by Layout
{type: B2 ? "btn" : "img", src: () => getIcon("Neg"), cb: () => respond(false)},
{fillx: 1},
{type: B2 ? "btn" : "img", src: () => getIcon("Pos"), cb: () => respond(true)},
]
}
]
}, options);
layout.render();
setUI({
mode: "custom",
back: goBack,
touch: (side, xy) => {
if (B2 && xy.y<Bangle.appRect.y) return;
if (side===1) respond(false);
if (side===2) respond(true);
},
btn: b => {
if (B2 || b===2) goBack();
else if (b===1) respond(true);
else respond(false);
},
swipe: dir => {
if (dir===RIGHT) showMain();
},
});
};
const showAlarm = function() {
// dismissing alarms doesn't seem to work, so this is simple */
setActive("alarm");
delete alarm.new;
Bangle.setLocked(false);
Bangle.setLCDPower(1);
const w = g.getWidth()-48,
lines = g.setFont(fontNormal).wrapString(alarm.title, w),
title = (lines.length>2) ? lines.slice(0, 2).join("\n")+"..." : lines.join("\n");
layout = new Layout({
type: "v", c: [
{
type: "h", fillx: 1, bgCol: g.theme.bg2, col: g.theme.fg2, c: [
alarm.body ? {type: "img", pad: 10, src: () => getIcon("alarm"), col: getIconColor("alarm")} : {},
{type: "txt", font: fontNormal, label: title ||/*LANG*/"Alarm", bgCol: g.theme.bg2, col: g.theme.fg2, fillx: 1, pad: 2, halign: 1},
]
},
alarm.body
? {type: "txt", font: fontNormal, label: alarm.body, fillx: 1, filly: 1, pad: 2, wrap: true}
: {type: "img", pad: 10, scale: 3, src: () => getIcon("alarm"), col: getIconColor("alarm")},
]
});
layout.render();
setUI({
mode: "custom",
back: goBack,
btn: b => {
if (B2 || b===2) goBack();
},
swipe: dir => {
if (dir===RIGHT) showMain();
},
});
};
/**
* Send message response, and delete it from list
* @param {string|boolean} reply Response text, false to dismiss (true to open on phone)
*/
const respondToMessage = function(reply) {
const msg = MESSAGES[messageNum];
if (msg) {
Bangle.messageResponse(msg, reply);
if (reply===false) remove(msg);
}
if (MESSAGES.length<1) goBack(); // no more messages
else showMessage((msg && reply===false) ? messageNum : messageNum+1); // show next message
};
const showMessageActions = function() {
let title = MESSAGES[messageNum].title || "";
if (g.setFont(fontNormal).stringMetrics(title).width>Bangle.appRect.w-(B2 ? 0 : 20)) {
title = g.wrapString("..."+title, Bangle.appRect.w-(B2 ? 0 : 20))[0].substring(3)+"...";
}
clearStuff();
let grid = {
"": {
title: title ||/*LANG*/"Message",
back: () => showMessage(messageNum),
cols: 3, // fit all replies on first row, dismiss on bottom
}
};
// Text replies don't work (yet)
// grid[/*LANG*/"OK"] = {icon: "Ok", col: "#0f0", cb: () => respondToMessage("\u{1F44D}")}; // "Thumbs up" emoji
// grid[/*LANG*/"Nak"] = {icon: "Nak", col: "#f00", cb: () => respondToMessage("\u{1F44E}")}; // "Thumbs down" emoji
// grid[/*LANG*/"No Phone"] = {icon: "NoPhone", col: "#f0f", cb: () => respondToMessage("\u{1F4F5}")}; // "No Mobile Phones" emoji
grid[/*LANG*/"Dismiss"] = {icon: "Trash", col: "#ff0", cb: () => respondToMessage(false)};
showGrid(grid);
};
/**
* Show message
*
* @param {number} [num=0] Message to show
* @param {boolean} [bottom=false] Scroll message to bottom right away
*/
let buzzing = false, moving = false, switching = false;
let h, fh, offset;
/**
* draw (sticky) footer
*/
const drawFooter = function() {
// left hint: swipe from left for main menu
g.reset().clearRect(Bangle.appRect.x, Bangle.appRect.y2-fh, Bangle.appRect.x2, Bangle.appRect.y2)
.setFont(fontTiny)
.setFontAlign(-1, 1) // bottom left
.drawString(
"\0"+atob("CAiBACBA/EIiAnwA")+ // back
"\0"+atob("CAiBAEgkEgkSJEgA"), // >>
Bangle.appRect.x+(B2 ? 1 : 28), Bangle.appRect.y2
);
// center message count+hints: swipe up/down for next/prev message
const footer = ` ${messageNum+1}/${MESSAGES.length} `,
fw = g.stringWidth(footer);
g.setFontAlign(0, 1); // bottom center
if (B2 && messageNum>0 && offset<=0)
g.drawString("\0"+atob("CAiBAABBIhRJIhQI"), Bangle.appRect.x+Bangle.appRect.w/2-fw/2, Bangle.appRect.y2); // ^ swipe to prev
g.drawString(footer, Bangle.appRect.x+Bangle.appRect.w/2, Bangle.appRect.y2);
if (B2 && messageNum<MESSAGES.length-1 && offset>=h-(Bangle.appRect.h-fh))
g.drawString("\0"+atob("CAiBABAoRJIoRIIA"), Bangle.appRect.x+Bangle.appRect.w/2+fw/2, Bangle.appRect.y2); // v swipe to next
// right hint: swipe from right for message actions
g.setFontAlign(1, 1) // bottom right
.drawString(
"\0"+atob("CAiBABIkSJBIJBIA")+ // <<
"\0"+atob("CAiBAP8AAP8AAP8A"), // = ("hamburger menu")
Bangle.appRect.x2-(B2 ? 1 : 28), Bangle.appRect.y2
);
};
const showMessage = function(num, bottom) {
if (num<0) num = 0;
if (!num) num = 0; // no number: show first
if (num>=MESSAGES.length) num = MESSAGES.length-1;
setActive("messages", num);
if (!MESSAGES.length) {
// I /think/ this should never happen...
return E.showPrompt(/*LANG*/"No Messages", {
title:/*LANG*/"Messages",
img: require("heatshrink").decompress(atob("kkk4UBrkc/4AC/tEqtACQkBqtUDg0VqAIGgoZFDYQIIM1sD1QAD4AIBhnqA4WrmAIBhc6BAWs8AIBhXOBAWz0AIC2YIC5wID1gkB1c6BAYFBEQPqBAYXBEQOqBAnDAIQaEnkAngaEEAPDFgo+IKA5iIOhCGIAFb7RqAIGgtUBA0VqobFgNVA")),
buttons: {/*LANG*/"Ok": 1}
}).then(showMain);
}
Bangle.setLocked(false);
Bangle.setLCDPower(1);
// only clear msg.new on user input
const msg = MESSAGES[messageNum]; // message
fh = 10; // footer height
offset = 0;
let oldOffset = 0;
const move = (dy) => {
offset = Math.max(0, Math.min(h-(Bangle.appRect.h-fh), offset+dy)); // clip at message height
dy = oldOffset-offset; // real dy
// move all elements to new offset
const offsetRecurser = function(l) {
if (l.y) l.y += dy;
if (l.c) l.c.forEach(offsetRecurser);
};
offsetRecurser(layout.l);
oldOffset = offset;
draw();
};
const draw = () => {
g.reset()
.clearRect(Bangle.appRect.x, Bangle.appRect.y, Bangle.appRect.x2, Bangle.appRect.y2-fh)
.setClipRect(Bangle.appRect.x, Bangle.appRect.y, Bangle.appRect.x2, Bangle.appRect.y2-fh);
g.reset = () => g.setColor(g.theme.fg).setBgColor(g.theme.bg); // stop Layout resetting ClipRect
layout.render();
if (layout.button && h>Bangle.appRect.h-fh && offset<h-(Bangle.appRect.h-fh)) {
// not yet at bottom: draw button "disabled", i.e. in fg+bg instead of fg2/fgH+bg2/bgH
g.theme = Object.assign({}, g.theme); // allow overriding
g.theme.fg2 = g.theme.fgH = g.theme.fg;
g.theme.bg2 = g.theme.bgH = g.theme.bg;
layout.render(layout.button);
delete g.theme; // restore original
}
delete g.reset;
// draw scrollbar (simply on top of everything)
if (h>(Bangle.appRect.h-fh)) {
const sbh = (Bangle.appRect.h-fh)/h*(Bangle.appRect.h-fh), // scrollbar height
y1 = Bangle.appRect.y+offset/h*(Bangle.appRect.h-fh), y2 = y1+sbh;
g.setColor(g.theme.bg).drawLine(Bangle.appRect.x2, Bangle.appRect.y, Bangle.appRect.x2, Bangle.appRect.y2-fh);
g.setColor(g.theme.fg).drawLine(Bangle.appRect.x2, y1, Bangle.appRect.x2, y2);
}
drawFooter();
};
const buzzOnce = () => {
if (buzzing) return;
buzzing = true;
Bangle.buzz(50).then(() => setTimeout(() => {buzzing = false;}, 500));
};
layout = getMessageLayout(msg);
h = layout.l.h; // message height
if (bottom) move(h); // scrolling backwards: jump to bottom of message
else draw();
const PAGE_SIZE = Bangle.appRect.h-fh;
const // shared B1/B2 handlers
back = () => {
delete msg.new; // we mark messages as read on any input
goBack();
},
swipe = dir => {
delete msg.new;
if (dir===RIGHT) showMain();
else if (dir===LEFT) showMessageActions();
},
touch = (side, xy) => {
delete msg.new;
if (h<=Bangle.appRect.h-fh || offset>=h-(Bangle.appRect.h-fh)) { // already at bottom
// B2: check for button-press
// setUI overrides Layout listeners, so we need to check for button presses ourselves
if (B2 && layout.button) {
const b = layout.button;
// the button is at the bottom of the screen, so we accept touches all the way down
if (xy.x>=b.x && xy.y>=b.y && xy.x<=b.x+b.w /*&& xy.y<=b.y+b.h*/) return b.cb();
}
if (B2 && xy.y<Bangle.appRect.y) return;
}
// first: pagedown until we reach the bottom
if (h>Bangle.appRect.h-fh && offset<h-(Bangle.appRect.h-fh)) return move(+PAGE_SIZE);
// B1: when scrolled all the way down, treat whole right-side as button
if (!B2 && layout.button && side===2) return layout.button.cb();
// touch anywhere else:
switch(settings().onTap) {
case 1: // Dismiss
return respondToMessage(false);
case 2: // Back
return goBack();
case 3: // Do nothing
return;
case 0: // Message Menu
default:
return showMessageActions();
}
};
if (B2) { // Bangle.js 2
setUI({
mode: "custom",
back: back,
swipe: swipe,
drag: e => {
delete msg.new;
if (!switching) {
const dy = -e.dy;
if (dy>0) { // up
if (h>Bangle.appRect.h-fh && offset<h-(Bangle.appRect.h-fh)) {
moving = true; // prevent scrolling right into next message
move(dy);
} else if (messageNum<MESSAGES.length-1) { // already at bottom: show next
if (!moving) { // don't scroll right through to next message
Bangle.buzz(30);
switching = true; // don't process any more drag events until we lift our finger
showMessage(messageNum+1);
}
} else { // already at bottom of last message
buzzOnce();
}
} else if (dy<0) { // down
if (offset>0) {
moving = true; // prevent scrolling right into prev message
move(dy);
} else if (messageNum>0) { // already at top: show prev
if (!moving) { // don't scroll right through to previous message
Bangle.buzz(30);
switching = true; // don't process any more drag events until we lift our finger
showMessage(messageNum-1, true);
}
} else { // already at top of first message
buzzOnce();
}
}
}
if (!e.b) {
// touch end: we can swipe to another message (if we reached the top/bottom) or move the new message
moving = false;
switching = false;
}
},
touch: touch,
});
} else { // Bangle.js 1
setUI({
mode: "updown",
back: back,
}, dir => {
delete msg.new;
if (dir===DOWN) {
if (h>Bangle.appRect.h-fh && offset<h-(Bangle.appRect.h-fh)) {
move(+PAGE_SIZE);
} else if (messageNum<MESSAGES.length-1) { // bottom reached: show next
Bangle.buzz(30);
showMessage(messageNum+1);
} else {
buzzOnce(); // already at bottom of last message
}
} else if (dir===UP) {
if (offset>0) {
move(-PAGE_SIZE);
} else if (messageNum>0) { // top reached: show previous
Bangle.buzz(30);
showMessage(messageNum-1, true);
} else {
buzzOnce(); // already at top of first message
}
} else { // button
showMessageActions();
}
});
Bangle.swipeHandler = swipe;
Bangle.on("swipe", Bangle.swipeHandler);
Bangle.touchHandler = touch;
Bangle.on("touch", Bangle.touchHandler);
} // Bangle.js 1/2
};
/**
* Determine message layout information: size, fonts, and wrapped title/body texts
*
* @param msg
* @returns {{h: number, w: number,
* src: (string),
* title: (string), titleFont: (string),
* body: (string), bodyFont: (string)}}
*/
const getMessageLayoutInfo = function(msg) {
// header: [icon][title]
// [ src]
//
// But: no title? -> use src as title
let w, src = msg.src || "",
title = msg.title || "",
body = msg.body || "",
h = 0, // total height
th = 0, // title height
ih = 46; // icon height: // icon(24) + internal padding(20) + icon<->src spacer(2)
if (!title) {
title = src;
src = "";
}
// top bar
if (title) {
w = Bangle.appRect.w-59; // icon(24) + padding:left(5) + padding:btn-txt(5) + internal btn padding(20) + padding:right(5)
title = g.setFont(fontNormal).wrapString(title, w).join("\n");
th += 2+g.stringMetrics(title).height; // 2px padding
}
if (src) {
w = 59; // icon(24) + padding:left(5) + padding:btn-txt(5) + internal btn padding(20) + padding:right(5)
src = g.setFont(fontTiny).wrapString(src, w).join("\n");
ih += g.stringMetrics(src).height;
}
h = Math.max(ih, th); // maximum of icon/title
// body
w = Bangle.appRect.w-4; // padding(2x2)
body = g.setFont(fontNormal).wrapString(msg.body, w).join("\n");
h += 4+g.stringMetrics(body).height; // padding(2x2)
if (settings().button) h += 44; // icon(24) + padding(2x2) + internal btn padding(16)
w = Bangle.appRect.w;
// always expand to <full height>-<(10x)footer>
h = Math.max(h, Bangle.appRect.h-10);
return {
src: src,
title: title,
body: body,
h: h,
w: w,
};
};
const getMessageLayout = function(msg) {
// Crafted so that on B2, with "medium" font, a message with
// icon + src + 2-line title + 2-line body + button
// fits exactly, i.e. no need for scrolling
const info = getMessageLayoutInfo(msg);
const hCol = msg.new ? g.theme.fgH : g.theme.fg2,
hBg = msg.new ? g.theme.bgH : g.theme.bg2;
// lie to Layout library about available space
Bangle.appRect = Object.assign({}, Bangle.appRect,
{w: info.w, h: info.h, x2: Bangle.appRect.x+info.w-1, y2: Bangle.appRect.y+info.h-1});
// make sure icon is not invisible
let imageCol = getImageColor(msg);
if (g.setColor(imageCol).getColor()==hBg) imageCol = hCol;
layout = new Layout({
type: "v", c: [
{
type: "h", fillx: 1, bgCol: hBg, col: hCol, c: [
{width: 3},
{
type: "v", c: [
{type: "img", /*pad: 2,*/ src: () => getMessageImage(msg), col: imageCol},
{height: 2},
info.src ? {type: "txt", font: fontTiny, label: info.src, bgCol: hBg, col: hCol} : {},
]
},
info.title ? {type: "txt", font: fontNormal, label: info.title, bgCol: hBg, col: hCol, fillx: 1, pad: 2} : {},
{width: 3},
]
},
{type: "txt", font: fontNormal, label: info.body, fillx: 1, filly: 1, pad: 2},
{filly: 1},
settings().button ? {
type: "h", c: [
B2 ? {} : {fillx: 1}, // Bangle.js 1: touching right side = press button
{id: "button", type: "btn", pad: 2, src: () => getIcon("trash"), cb: () => respondToMessage(false)},
]
} : {},
]
});
layout.update();
delete Bangle.appRect;
return layout;
};
/** this is a timeout if the app has started and is showing a single message
but the user hasn't seen it (e.g. no user input) - in which case
we should start a timeout for settings().unreadTimeout to return
to the clock. */
let unreadTimeout;
/**
* Stop auto-unload timeout and buzzing, remove listeners for this function
*/
const clearUnreadStuff = function() {
require("messages").stopBuzz();
if (unreadTimeout) clearTimeout(unreadTimeout);
unreadTimeout = undefined;
["touch", "drag", "swipe"].forEach(l => Bangle.removeListener(l, clearUnreadStuff));
watches.forEach(w => clearWatch(w));
watches = [];
};
let messageNum, // currently visible message
watches = [], // button watches
savedMusic = false; // did we find a stored "music" message when loading?
// special messages
let call, music, map, alarm;
/**
* Find special messages, and remove them from MESSAGES
*/
const findSpecials = function() {
let idx = MESSAGES.findIndex(m => m.id==="call");
if (idx>=0) call = MESSAGES.splice(idx, 1)[0];
idx = MESSAGES.findIndex(m => m.id==="music");
if (idx>=0) {
music = MESSAGES.splice(idx, 1)[0];
savedMusic = true;
}
idx = MESSAGES.findIndex(m => m.id==="map");
if (idx>=0) map = MESSAGES.splice(idx, 1)[0];
idx = MESSAGES.findIndex(m => m.src && m.src.toLowerCase().startsWith("alarm"));
if (idx>=0) alarm = MESSAGES.splice(idx, 1)[0];
};
// Internal setUI suppresses Bangle.uiRemove between internal screens, so we
// need to call setUI to run uiRemove from previous app when fast-loaded.
Bangle.setUI();
Bangle.loadWidgets();
require("messages").toggleWidget(false);
Bangle.drawWidgets();
findSpecials(); // sets global vars for special messages
// any message we asked to show?
const showIdx = MESSAGES.findIndex(m => m.show);
// any new text messages?
const newIdx = MESSAGES.findIndex(m => m.new);
// figure out why the app was loaded
if (showIdx>=0) show(showIdx);
else if (call && call.new) showCall();
else if (alarm && alarm.new) showAlarm();
else if (map && map.new) showMap();
else if (music && music.new && settings().openMusic) {
if (settings().alwaysShowMusic===undefined) {
// if not explicitly disabled, enable this the first time we see music
let s = settings();
s.alwaysShowMusic = true;
require("Storage").writeJSON("messages.settings.json", s);
}
showMusic();
}
// check for new message last: Maybe we already showed it, but timed out before
// if that happened, and we're loading for e.g. music now, we want to show the music screen
else if (newIdx>=0) {
showMessage(newIdx);
// auto-loaded for message(s): auto-close after timeout
let unreadTimeoutSecs = settings().unreadTimeout;
if (unreadTimeoutSecs===undefined) unreadTimeoutSecs = 60;
if (unreadTimeoutSecs) {
unreadTimeout = setTimeout(load, unreadTimeoutSecs*1000);
}
} else if (MESSAGES.length) { // not autoloaded, but we have messages to show
back = "main"; // prevent "back" from loading clock
showMessage();
} else showMain();
// stop buzzing, auto-close timeout on input
["touch", "drag", "swipe"].forEach(l => Bangle.on(l, clearUnreadStuff));
(B2 ? [BTN1] : [BTN1, BTN2, BTN3]).forEach(b => watches.push(setWatch(clearUnreadStuff, b, false)));
}