diff --git a/apps/messagelist/ChangeLog b/apps/messagelist/ChangeLog new file mode 100644 index 000000000..759f68777 --- /dev/null +++ b/apps/messagelist/ChangeLog @@ -0,0 +1 @@ +0.01: New app! \ No newline at end of file diff --git a/apps/messagelist/README.md b/apps/messagelist/README.md new file mode 100644 index 000000000..776d0d0e6 --- /dev/null +++ b/apps/messagelist/README.md @@ -0,0 +1,69 @@ +# Message List + +Display messages inline as a single list: +Displays one message at a time, if it doesn't fit on the screen you can scroll +up/down. When you reach the bottom, you can scroll on to the next message. + +## Installation +**First** uninstall the default [Message UI](/?id=messagegui) app (`messagegui`, +not the library!). +Then install this app. + +## Screenshots + +### Main menu: +![Screenshot](screenshot0.png) + +### Unread message: +![Screenshot](screenshot1.png) +The chevrons are hints for swipe actions: +- Swipe right to go back +- Swipe left for the message-actions menu +- Swipe down to show the previous message: We are currently viewing message 2 of 2, + so message 1 is "above" this one. + +### Long (read) message: +![Screenshot](screenshot2.png) +The button is disabled until you scroll all the way to the bottom. + +### Music: +![Screenshot](screenshot3.png) +Minimal setup: album name and buttons disabled through settings. +Swipe for next/previous song, tap to pause/resume. + +## Settings + +### Interface +* `Font size` - The font size used when displaying messages/music. +* `On Tap` - If messages are too large to fit on the screen, tapping the screen scrolls down. + This is the action to take when tapping a message after reaching the bottom: + - `Message menu`: Open menu with message actions + - `Dismiss`: Dismiss message right away + - `Back`: Go back to clock/main menu + - `Nothing`: Do nothing +* `Dismiss button` - Show inline button to dismiss message right away + +### Behaviour +* `Vibrate` - The pattern of buzzes when a new message is received. +* `Vibrate for calls` - The pattern of buzzes for incoming calls. +* `Vibrate for alarms` - The pattern of buzzes for (phone) alarms. +* `Repeat` - How often buzzes repeat - the default of 4 means the Bangle will buzz every 4 seconds. +* `Unread timer` - When a new message is received the Messages app is opened. + If there is no user input for this amount of time then the app will exit and return to the clock. +* `Auto-open` - Automatically open app when a new message arrives. +* `Respect quiet mode` - Prevent auto-opening during quiet mode. + +### Music +* `Auto-open` - Automatically open app when music starts playing. +* `Always visible` - Show "music" in the main menu even when nothing is playing. +* `Buttons` - Show `previous`/`play/pause`/`next` buttons on music screen. +* `Show album` - Display album names? + + +### Util +* `Delete all` - Erase all messages. + + +## Attributions + +Some icons used in this app are from https://icons8.com diff --git a/apps/messagelist/TODO.txt b/apps/messagelist/TODO.txt new file mode 100644 index 000000000..3a6d7b664 --- /dev/null +++ b/apps/messagelist/TODO.txt @@ -0,0 +1,17 @@ +## Nice to have: +* Add labels to B1 music HW buttons +* Add volume buttons to B2 music screen (when controls are enabled) +* Draw messages ourselves instead of piling hacks on Layout +* Make sure all icons are 24x24px: icon sizes affect layout +* Check/optimize layout for B1, other fonts (scrolling for just 5px is a shame) + +## Wishlist: +* Option to swipe-dismiss (instead of action menu) +* Maybe refactor showGrid() out into a general-use module? + +* Message replies (needs `android` support) +* Customize replies +* Custom replies (i.e. `textinput`) +* Hooks to add custom replies/actions, + e.g. external code could add "Send intent" option to Home Assistant messages + Maybe just use this for all replies, so we don't hardcode anything in "messages"? diff --git a/apps/messagelist/app-icon.js b/apps/messagelist/app-icon.js new file mode 100644 index 000000000..6ed3c1141 --- /dev/null +++ b/apps/messagelist/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEw4UA///rkcAYP9ohL/ABMBqoAEoALDioLFqgLDBQoABERIkEBZcFBY9QBed61QAC1oLF7wLD24LF24LD7wLF1vqBQOrvQLFA4IuC9QLFD4IuC1QLGGAQOBBYwgBEwQLHvQBBEZHVq4jI7wWBHY5TLNZaDLTZazLffMBBY9ABZsABY4KCgEVBQtUBYYkGEQYA/AAwA=")) diff --git a/apps/messagelist/app.js b/apps/messagelist/app.js new file mode 100644 index 000000000..ebd5d4217 --- /dev/null +++ b/apps/messagelist/app.js @@ -0,0 +1,1208 @@ +/* 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) { + options = Object.assign({remove: () => uiRemove()}, options); + Bangle.setUI(options, cb); + Bangle.on("message", onMessage); + }; + + 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: "", artist: "", 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.y2*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 { + 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 (valmax) 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 (rcbs[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 { + 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 { + 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=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(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.yBangle.appRect.h-fh && offset { + delete msg.new; + if (!switching) { + const dy = -e.dy; + if (dy>0) { // up + if (h>Bangle.appRect.h-fh && offset0) { + 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 && offset0) { + 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 -<(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]; + }; + if (MESSAGES!==undefined) { // only if loading MESSAGES worked + g.reset().clear(); + 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))); + } +} \ No newline at end of file diff --git a/apps/messagelist/app.png b/apps/messagelist/app.png new file mode 100644 index 000000000..6eae4bb96 Binary files /dev/null and b/apps/messagelist/app.png differ diff --git a/apps/messagelist/boot.js b/apps/messagelist/boot.js new file mode 100644 index 000000000..994a2cfed --- /dev/null +++ b/apps/messagelist/boot.js @@ -0,0 +1,3 @@ +(function() { + Bangle.on("message", require("messagegui").messageListener); +})(); \ No newline at end of file diff --git a/apps/messagelist/lib.js b/apps/messagelist/lib.js new file mode 100644 index 000000000..33b6d9d69 --- /dev/null +++ b/apps/messagelist/lib.js @@ -0,0 +1,246 @@ +// Handle incoming messages while the app is not loaded +// The messages app overrides Bangle.messageListener +// (placed in separate file, so we don't read this all at boot time) +exports.messageListener = function(type, msg) { + if (msg.handled || (global.__FILE__ && __FILE__.startsWith("messagelist."))) return; // already handled/app open + // clean up, in case previous message didn't load the app after all + if (exports.loadTimeout) clearTimeout(exports.loadTimeout); + delete exports.loadTimeout; + delete exports.buzz; + const quiet = () => (require("Storage").readJSON("setting.json", 1) || {}).quiet; + /** + * Quietly load the app for music/map, if not already loading + */ + function loadQuietly(msg) { + if (exports.loadTimeout) return; // already loading + exports.loadTimeout = setTimeout(function() { + Bangle.load("messagelist.app.js"); + }, 500); + } + function loadNormal(msg) { + if (exports.loadTimeout) clearTimeout(exports.loadTimeout); // restart timeout + exports.loadTimeout = setTimeout(function() { + delete exports.loadTimeout; + // check there are still new messages (for #1362) + let messages = require("messages").getMessages(msg); + (Bangle.MESSAGES || []).forEach(m => require("messages").apply(m, messages)); + if (!messages.some(m => m.new)) return; // don't use `status()`: also load for new music! + // if we're in a clock, or it's important, open app + if (Bangle.CLOCK || msg.important) { + if (exports.buzz) require("messages").buzz(msg.src); + Bangle.load("messagelist.app.js"); + } + }, 500); + } + + /** + * Mark message as handled, and save it for the app + */ + const handled = () => { + if (!Bangle.MESSAGES) Bangle.MESSAGES = []; + require("messages").apply(msg, Bangle.MESSAGES); + if (!Bangle.MESSAGES.length) delete Bangle.MESSAGES; + if (msg.t==="remove") require("messages").save(msg); + else msg.handled = true; + }; + /** + * Write messages to flash after all, when not laoding the app + */ + const saveToFlash = () => { + (Bangle.MESSAGES||[]).forEach(m=>require("messages").save(m)); + delete Bangle.MESSAGES; + } + + switch(type) { + case "music": + if (!Bangle.CLOCK) return; + // only load app if we are playing, and we know which song + if (msg.state!=="play" || !msg.title) return; + if (exports.openMusic===undefined) { + // only read settings for first music message + exports.openMusic = !!(exports.settings().openMusic); + } + if (!exports.openMusic) return; // we don't care about music + if (quiet()) return; + msg.new = true; + handled(); + return loadQuietly(); + + case "map": + handled(); + if (msg.t!=="remove" && Bangle.CLOCK) loadQuietly(); + else saveToFlash(); + return; + + case "text": + handled(); + if (exports.settings().autoOpen===false) return saveToFlash(); + if (quiet()) return saveToFlash(); + if (msg.t!=="add" || !msg.new || !(Bangle.CLOCK || msg.important)) { + // not important enough to load the app + if (msg.t==="add" && msg.new) require("messages").buzz(msg); + return saveToFlash(); + } + if (msg.t==="add" && msg.new) exports.buzz = true; + return loadNormal(msg); + + case "alarm": + if (quiet()<2) return saveToFlash(); + // fall through + case "call": + handled(); + exports.buzz = true; + return loadNormal(msg); + + // case "clearAll": do nothing + } +}; + +exports.settings = function() { + return Object.assign({ + // Interface // + fontSize: 1, + onTap: 0, // [Message menu, Dismiss, Back, Nothing] + button: true, + + // Behaviour // + vibrate: ":", + vibrateCalls: ":", + vibrateAlarms: ":", + repeat: 4, + vibrateTimeout: 60, + unreadTimeout: 60, + autoOpen: true, + + // Music // + openMusic: true, + // no default: alwaysShowMusic (auto-enabled by app when music happens) + showAlbum: true, + musicButtons: false, + + // Widget // + flash: true, + // showRead: false, + + // Utils // + }, + // fall back to default app settings if not set for messagelist + (require("Storage").readJSON("messages.settings.json", true) || {}), + (require("Storage").readJSON("messagelist.settings.json", true) || {})); +}; + +/** + * @param {string} icon Icon name + * @returns string Icon image string, for use with g.drawImage() + */ +exports.getIcon = function(icon) { + // TODO: icons should be 24x24px with 1bpp colors + switch(icon.toLowerCase()) { + // generic icons: + case "alert": + return atob("GBgBAAAAAP8AA//AD8PwHwD4HBg4ODwcODwccDwOcDwOYDwGYDwGYBgGYBgGcBgOcAAOOBgcODwcHDw4Hxj4D8PwA//AAP8AAAAA"); + case "alarm": + case "alarmclockreceiver": + return atob("GBjBAP////8AAAAAAAACAEAHAOAefng5/5wTgcgHAOAOGHAMGDAYGBgYGBgYGBgYGBgYDhgYBxgMATAOAHAHAOADgcAB/4AAfgAAAAAAAAA="); + case "back": // TODO: 22x22 + return atob("FhYBAAAAEAAAwAAHAAA//wH//wf//g///BwB+DAB4EAHwAAPAAA8AADwAAPAAB4AAHgAB+AH/wA/+AD/wAH8AA=="); + case "calendar": + return atob("GBiBAAAAAAAAAAAAAA//8B//+BgAGBgAGBgAGB//+B//+B//+B9m2B//+B//+Btm2B//+B//+Btm+B//+B//+A//8AAAAAAAAAAAAA=="); + case "mail": // TODO: 28x18 + case "sms message": + case "notification": + return atob("HBKBAD///8H///iP//8cf//j4//8f5//j/x/8//j/H//H4//4PB//EYj/44HH/Hw+P4//8fH//44///xH///g////A=="); + case "map": // TODO: 25x25, + return atob("GRmBAAAAAAAAAAAAAAIAYAHx/wH//+D/+fhz75w/P/4f//8P//uH///D///h3f/w4P+4eO/8PHZ+HJ/nDu//g///wH+HwAYAIAAAAAAAAAAAAAA="); + case "menu": + return atob("GBiBAAAAAAAAAAAAAAAAAP///////wAAAAAAAAAAAAAAAAAAAP///////wAAAAAAAAAAAAAAAAAAAP///////wAAAAAAAAAAAAAAAA=="); + case "music": // TODO: 22x22 + return atob("FhaBAH//+/////////////h/+AH/4Af/gB/+H3/7/f/v9/+/3/7+f/vB/w8H+Dwf4PD/x/////////////3//+A="); + case "nak": // TODO: 22x25 + return atob("FhmBAA//wH//j//+P//8///7///v//+///7//////////////v//////////z//+D8AAPwAAfgAB+AAD4AAPgAAeAAB4AAHAAA=="); + case "neg": // TODO: 22x22 + return atob("FhaBADAAMeAB78AP/4B/fwP4/h/B/P4D//AH/4AP/AAf4AB/gAP/AB/+AP/8B/P4P4fx/A/v4B//AD94AHjAAMA="); + case "next": + return atob("GBiBAAAAAAAAAAAAAAwAcB8A+B+A+B/g+B/4+B/8+B//+B//+B//+B//+B//+B//+B/8+B/4+B/g+B+A+B8A+AwAcAAAAAAAAAAAAA=="); + case "nophone": // TODO: 30x30 + return atob("Hh6BAAAAAAGAAAAHAAAADgAAABwADwA4Af8AcA/8AOB/+AHH/+ADv/8AB//wAA/HAAAeAAACOAAADHAAAHjgAAPhwAAfg4AAfgcAAfwOAA/wHAA/wDgA/gBwA/gA4AfAAcAfAAOAGAAHAAAADgAAABgAAAAA"); + case "ok": // TODO: 22x25 + return atob("FhmBAAHAAAeAAB4AAPgAA+AAH4AAfgAD8AAPwAD//+//////////////7//////////////v//+///7///v//8///gf/+A//wA=="); + case "pause": + return atob("GBiBAAAAAAAAAAAAAAOBwAfD4AfD4AfD4AfD4AfD4AfD4AfD4AfD4AfD4AfD4AfD4AfD4AfD4AfD4AfD4AfD4AOBwAAAAAAAAAAAAA=="); + case "phone": // TODO: 23x23 + case "call": + return atob("FxeBABgAAPgAAfAAB/AAD+AAH+AAP8AAP4AAfgAA/AAA+AAA+AAA+AAB+AAB+AAB+OAB//AB//gB//gA//AA/8AAf4AAPAA="); + case "play": + return atob("GBiBAAAAAAAAAAAAAAcAAA+AAA/gAA/4AA/8AA//AA//wA//4A//8A//8A//4A//wA//AA/8AA/4AA/gAA+AAAcAAAAAAAAAAAAAAA=="); + case "pos": // TODO: 25x20 + return atob("GRSBAAAAAYAAAcAAAeAAAfAAAfAAAfAAAfAAAfAAAfBgAfA4AfAeAfAPgfAD4fAA+fAAP/AAD/AAA/AAAPAAADAAAA=="); + case "previous": + return atob("GBiBAAAAAAAAAAAAAA4AMB8A+B8B+B8H+B8f+B8/+B//+B//+B//+B//+B//+B//+B8/+B8f+B8H+B8B+B8A+A4AMAAAAAAAAAAAAA=="); + case "settings": // TODO: 20x20 + return atob("FBSBAAAAAA8AAPABzzgf/4H/+A//APnwfw/n4H5+B+fw/g+fAP/wH/+B//gc84APAADwAAAA"); + case "to do": + return atob("GBgBAAAAAAAAAAAwAAB4AAD8AAH+AAP/DAf/Hg//Px/+f7/8///4///wf//gP//AH/+AD/8AB/4AA/wAAfgAAPAAAGAAAAAAAAAA"); + case "trash": + return atob("GBiBAAAAAAAAAAB+AA//8A//8AYAYAYAYAZmYAZmYAZmYAZmYAZmYAZmYAZmYAZmYAZmYAZmYAZmYAYAYAYAYAf/4AP/wAAAAAAAAA=="); + case "unknown": // TODO: 30x30 + return atob("Hh6BAAAAAAAAAAAAAAAAAAPwAAA/8AAB/+AAD//AAD4fAAHwPgAHwPgAAAPgAAAfAAAA/AAAD+AAAH8AAAHwAAAPgAAAPgAAAPgAAAAAAAAAAAAAAAAAAHAAAAPgAAAPgAAAPgAAAHAAAAAAAAAAAAAAAAAA"); + case "unread": // TODO: 29x24 + return atob("HRiBAAAAH4AAAf4AAB/4AAHz4AAfn4AA/Pz/5+fj/z8/j/n5/j/P//j/Pn3j+PPPx+P8fx+Pw/x+AF/B4A78RiP3xwOPvHw+Pcf/+Ox//+NH//+If//+B///+A=="); + default: //should never happen + return exports.getIcon("unknown"); + } +}; +/** + * @param {string} icon Icon + * @returns {string} Color to use with g.setColor() + */ +exports.getColor = function(icon) { + switch(icon.toLowerCase()) { + // generic colors, using B2-safe colors + case "alert": + return "#ff0"; + case "alarm": + return "#fff"; + case "calendar": + return "#f00"; + case "mail": + return "#ff0"; + case "map": + return "#f0f"; + case "music": + return "#f0f"; + case "neg": + return "#f00"; + case "notification": + return "#0ff"; + case "phone": + case "call": + return "#0f0"; + case "settings": + return "#000"; + case "sms message": + return "#0ff"; + case "trash": + return "#f00"; + case "unknown": + return g.theme.fg; + case "unread": + return "#ff0"; + default: + return g.theme.fg; + } +}; + +/** + * Launch GUI app with given message + * @param {object} msg + */ +exports.open = function(msg) { + if (msg && msg.id && !msg.show) { + // store which message to load + msg.show = 1; + } + + Bangle.load((msg && msg.new && msg.id!=="music") ? "messagelist.new.js" : "messagelist.app.js"); +}; diff --git a/apps/messagelist/metadata.json b/apps/messagelist/metadata.json new file mode 100644 index 000000000..7947e2db4 --- /dev/null +++ b/apps/messagelist/metadata.json @@ -0,0 +1,28 @@ +{ + "id": "messagelist", + "name": "Message List", + "version": "0.01", + "description": "Display notifications from iOS and Gadgetbridge/Android as a list", + "icon": "app.png", + "type": "app", + "tags": "tool,system", + "screenshots": [ + {"url": "screenshot0.png"}, + {"url": "screenshot1.png"}, + {"url": "screenshot2.png"}, + {"url": "screenshot3.png"} + ], + "supports": ["BANGLEJS","BANGLEJS2"], + "dependencies" : { "messageicons":"module" }, + "provides_modules": ["messagegui"], + "readme": "README.md", + "storage": [ + {"name":"messagelist.boot.js","url":"boot.js"}, + {"name":"messagegui","url":"lib.js"}, + {"name":"messagelist.app.js","url":"app.js"}, + {"name":"messagelist.settings.js","url":"settings.js"}, + {"name":"messagelist.img","url":"app-icon.js","evaluate":true} + ], + "data": [{"name":"messagelist.settings.json"}], + "sortorder": -9 +} diff --git a/apps/messagelist/screenshot0.png b/apps/messagelist/screenshot0.png new file mode 100644 index 000000000..b6f37c053 Binary files /dev/null and b/apps/messagelist/screenshot0.png differ diff --git a/apps/messagelist/screenshot1.png b/apps/messagelist/screenshot1.png new file mode 100644 index 000000000..f4d4db9fa Binary files /dev/null and b/apps/messagelist/screenshot1.png differ diff --git a/apps/messagelist/screenshot2.png b/apps/messagelist/screenshot2.png new file mode 100644 index 000000000..67c192a1c Binary files /dev/null and b/apps/messagelist/screenshot2.png differ diff --git a/apps/messagelist/screenshot3.png b/apps/messagelist/screenshot3.png new file mode 100644 index 000000000..02fed81a7 Binary files /dev/null and b/apps/messagelist/screenshot3.png differ diff --git a/apps/messagelist/settings.js b/apps/messagelist/settings.js new file mode 100644 index 000000000..cd2767336 --- /dev/null +++ b/apps/messagelist/settings.js @@ -0,0 +1,139 @@ +(function(back) { + let settings = require("messagegui").settings(); + const inApp = (global.__FILE__ && __FILE__.startsWith("messagelist.")); + + function updateSetting(setting, value) { + settings[setting] = value; + let file; + switch(setting) { + case "flash": + case "showRead": + case "iconColorMode": + case "maxMessages": + case "maxUnreadTimeout": + case "openMusic": + case "repeat": + case "unlockWatch": + case "unreadTimeout": + case "vibrate": + case "vibrateCalls": + case "vibrateTimeout": + // Default app has this setting: update that file + file = "messages"; + break; + default: + // write to our own settings file + file = "messagelist"; + } + file += ".settings.json"; + let saved = require("Storage").readJSON(file, true) || {}; + saved[setting] = value; + require("Storage").writeJSON(file, saved); + } + + function toggler(setting) { + return { + value: !!settings[setting], + onchange: v => updateSetting(setting, v) + }; + } + + function showIfMenu() { + const tapOptions = [/*LANG*/"Message menu",/*LANG*/"Dismiss",/*LANG*/"Back",/*LANG*/"Nothing"]; + E.showMenu({ + "": {"title": /*LANG*/"Interface"}, + "< Back": () => showMainMenu(), + /*LANG*/"Font size": { + value: 0|settings.fontSize, + min: 0, max: 2, + format: v => [/*LANG*/"Small",/*LANG*/"Medium",/*LANG*/"Large",/*LANG*/"Huge"][v], + onchange: v => updateSetting("fontSize", v) + }, + /*LANG*/"On Tap": { + value: settings.onTap, + min: 0, max: tapOptions.length-1, wrap: true, + format: v => tapOptions[v], + onchange: v => updateSetting("onTap", v) + }, + /*LANG*/"Dismiss button": toggler("button"), + }); + } + + function showBMenu() { + E.showMenu({ + "": {"title": /*LANG*/"Behaviour"}, + "< Back": () => showMainMenu(), + /*LANG*/"Vibrate": require("buzz_menu").pattern(settings.vibrate, v => updateSetting("vibrate", v)), + /*LANG*/"Vibrate for calls": require("buzz_menu").pattern(settings.vibrateCalls, v => updateSetting("vibrateCalls", v)), + /*LANG*/"Vibrate for alarms": require("buzz_menu").pattern(settings.vibrateAlarms, v => updateSetting("vibrateAlarms", v)), + /*LANG*/"Repeat": { + value: settings.repeat, + min: 0, max: 10, + format: v => v ? v+"s" :/*LANG*/"Off", + onchange: v => updateSetting("repeat", v) + }, + /*LANG*/"Vibrate timer": { + value: settings.vibrateTimeout, + min: 0, max: 240, step: 10, + format: v => v ? v+"s" :/*LANG*/"Forever", + onchange: v => updateSetting("vibrateTimeout", v) + }, + /*LANG*/"Unread timer": { + value: settings.unreadTimeout, + min: 0, max: 240, step: 10, + format: v => v ? v+"s" :/*LANG*/"Off", + onchange: v => updateSetting("unreadTimeout", v) + }, + /*LANG*/"Auto-open": toggler("autoOpen"), + }); + } + + function showMusicMenu() { + E.showMenu({ + "": {"title": /*LANG*/"Music"}, + "< Back": () => showMainMenu(), + /*LANG*/"Auto-open": toggler("openMusic"), + /*LANG*/"Always visible": toggler("alwaysShowMusic"), + /*LANG*/"Buttons": toggler("musicButtons"), + /*LANG*/"Show album": toggler("showAlbum"), + }); + } + + function showWidMenu() { + E.showMenu({ + "": {"title": /*LANG*/"Widget"}, + "< Back": () => showMainMenu(), + /*LANG*/"Flash icon": toggler("flash"), + // /*LANG*/"Show Read": toggler("showRead"), + }); + } + + function showUtilsMenu() { + let m = E.showMenu({ + "": {"title": /*LANG*/"Utilities"}, + "< Back": () => showMainMenu(), + /*LANG*/"Delete all": () => { + E.showPrompt(/*LANG*/"Are you sure?", + {title:/*LANG*/"Delete All Messages"}) + .then(isYes => { + if (isYes) require("messages").write([]); + showUtilsMenu(); + }); + } + }); + } + + function showMainMenu() { + E.showMenu({ + "": {"title": inApp ?/*LANG*/"Settings" :/*LANG*/"Messages"}, + "< Back": back, + /*LANG*/"Interface": () => showIfMenu(), + /*LANG*/"Behaviour": () => showBMenu(), + /*LANG*/"Music": () => showMusicMenu(), + /*LANG*/"Widget": () => showWidMenu(), + /*LANG*/"Utils": () => showUtilsMenu(), + }); + } + + showMainMenu(); +}); diff --git a/bin/sanitycheck.js b/bin/sanitycheck.js old mode 100644 new mode 100755 index 838f99895..82b2896b8 --- a/bin/sanitycheck.js +++ b/bin/sanitycheck.js @@ -94,6 +94,7 @@ const INTERNAL_FILES_IN_APP_TYPE = { // list of app types and files they SHOULD var KNOWN_WARNINGS = [ "App gpsrec data file wildcard .gpsrc? does not include app ID", "App owmweather data file weather.json is also listed as data file for app weather", + "App messagegui storage file messagegui is also listed as storage file for app messagelist", ]; function globToRegex(pattern) {