Merge pull request #2217 from bruceblore/pomoplus-rpnsci-stlap-stlapview-bgtimer-random-infoclk-gallery
Added bgtimer, gallery, infoclk, pomoplus, random, rpnsci, stlap, stlapview appspull/1498/head
|
@ -0,0 +1,2 @@
|
|||
0.01: New app!
|
||||
0.02: Submitted to app loader
|
|
@ -0,0 +1,18 @@
|
|||
# Gallery
|
||||
|
||||
A simple gallery app
|
||||
|
||||
## Usage
|
||||
|
||||
Upon opening the gallery app, you will be presented with a list of images that you can display. Tap the image to show it. Brightness will be set to full, and the screen timeout will be disabled. When you are done viewing the image, you can tap the screen to go back to the list of images. Press BTN1 to flip the image upside down.
|
||||
|
||||
## Adding images
|
||||
|
||||
1. The gallery app does not perform any scaling, and does not support panning. Therefore, you should use your favorite image editor to produce an image of the appropriate size for your watch. (240x240 for Bangle 1 or 176x176 for Bangle 2.) How you achieve this is up to you. If on a Bangle 2, I recommend adjusting the colors here to comply with the color restrictions.
|
||||
|
||||
2. Upload your image to the [Espruino image converter](https://www.espruino.com/Image+Converter). I recommend enabling compression and choosing one of the following color settings:
|
||||
* 16 bit RGB565 for Bangle 1
|
||||
* 3 bit RGB for Bangle 2
|
||||
* 1 bit black/white for monochrome images that you want to respond to your system theme. (White will be rendered as your foreground color and black will be rendered as your background color.)
|
||||
|
||||
3. Set the output format to an image string, copy it into the [IDE](https://www.espruino.com/ide/), and set the destination to a file in storage. The file name should begin with "gal-" (without the quotes) and end with ".img" (without the quotes) to appear in the gallery. Note that the gal- prefix and .img extension will be removed in the UI. Upload the file.
|
|
@ -0,0 +1,52 @@
|
|||
const storage = require('Storage');
|
||||
|
||||
let imageFiles = storage.list(/^gal-.*\.img/).sort();
|
||||
|
||||
let imageMenu = { '': { 'title': 'Gallery' } };
|
||||
|
||||
for (let fileName of imageFiles) {
|
||||
let displayName = fileName.substr(4, fileName.length - 8); // Trim off the 'gal-' and '.img' for a friendly display name
|
||||
imageMenu[displayName] = eval(`() => { drawImage("${fileName}"); }`); // Unfortunately, eval is the only reasonable way to do this
|
||||
}
|
||||
|
||||
let cachedOptions = Bangle.getOptions(); // We will change the backlight and timeouts later, and need to restore them when displaying the menu
|
||||
let backlightSetting = storage.readJSON('setting.json').brightness; // LCD brightness is not included in there for some reason
|
||||
|
||||
let angle = 0; // Store the angle of rotation
|
||||
let image; // Cache the image here because we access it in multiple places
|
||||
|
||||
function drawMenu() {
|
||||
Bangle.removeListener('touch', drawMenu); // We no longer want touching to reload the menu
|
||||
Bangle.setOptions(cachedOptions); // The drawImage function set no timeout, undo that
|
||||
Bangle.setLCDBrightness(backlightSetting); // Restore backlight
|
||||
image = undefined; // Delete the image from memory
|
||||
|
||||
E.showMenu(imageMenu);
|
||||
}
|
||||
|
||||
function drawImage(fileName) {
|
||||
E.showMenu(); // Remove the menu to prevent it from breaking things
|
||||
setTimeout(() => { Bangle.on('touch', drawMenu); }, 300); // Touch the screen to go back to the image menu (300ms timeout to allow user to lift finger)
|
||||
Bangle.setOptions({ // Disable display power saving while showing the image
|
||||
lockTimeout: 0,
|
||||
lcdPowerTimeout: 0,
|
||||
backlightTimeout: 0
|
||||
});
|
||||
Bangle.setLCDBrightness(1); // Full brightness
|
||||
|
||||
image = eval(storage.read(fileName)); // Sadly, the only reasonable way to do this
|
||||
g.clear().reset().drawImage(image, 88, 88, { rotate: angle });
|
||||
}
|
||||
|
||||
setWatch(info => {
|
||||
if (image) {
|
||||
if (angle == 0) angle = Math.PI;
|
||||
else angle = 0;
|
||||
Bangle.buzz();
|
||||
|
||||
g.clear().reset().drawImage(image, 88, 88, { rotate: angle })
|
||||
}
|
||||
}, BTN1, { repeat: true });
|
||||
|
||||
// We don't load the widgets because there is no reasonable way to unload them
|
||||
drawMenu();
|
|
@ -0,0 +1 @@
|
|||
require("heatshrink").decompress(atob("mEwgIOLgf/AAX8Av4FBJgkMAos/CIfMAv4Fe4AF/Apq5EAAw"))
|
After Width: | Height: | Size: 249 B |
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"id": "gallery",
|
||||
"name": "Gallery",
|
||||
"version": "0.02",
|
||||
"description": "A gallery that lets you view images uploaded with the IDE (see README)",
|
||||
"readme": "README.md",
|
||||
"icon": "icon.png",
|
||||
"type": "app",
|
||||
"tags": "tools",
|
||||
"supports": [
|
||||
"BANGLEJS2",
|
||||
"BANGLEJS"
|
||||
],
|
||||
"allow_emulator": true,
|
||||
"storage": [
|
||||
{
|
||||
"name": "gallery.app.js",
|
||||
"url": "app.js"
|
||||
},
|
||||
{
|
||||
"name": "gallery.img",
|
||||
"url": "icon.js",
|
||||
"evaluate": true
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
0.01: New app!
|
||||
0.02-0.07: Bug fixes
|
||||
0.08: Submitted to the app loader
|
|
@ -0,0 +1,33 @@
|
|||
# Informational clock
|
||||
|
||||
A configurable clock with extra info and shortcuts when unlocked, but large time when locked
|
||||
|
||||
## Information
|
||||
|
||||
The clock has two different screen arrangements, depending on whether the watch is locked or unlocked. The most commonly viewed piece of information is the time, so when the watch is locked it optimizes for the time being visible at a glance without the backlight. The hours and minutes take up nearly the entire top half of the display, with the date and seconds taking up nearly the entire bottom half. The day progress bar is between them if enabled, unless configured to be on the bottom row. The bottom row can be configured to display a weather summary, step count, step count and heart rate, the daily progress bar, or nothing.
|
||||
|
||||
When the watch is unlocked, it can be assumed that the backlight is on and the user is actively looking at the watch, so instead we can optimize for information density. The bottom half of the display becomes shortcuts, and the top half of the display becomes 4 rows of information (date and time, step count and heart rate, 2 line weather summary) + an optional daily progress bar. (The daily progress bar can be independently enabled when locked and unlocked.)
|
||||
|
||||
Most things are self-explanatory, but the day progress bar might not be. The day progress bar is intended to show approximately how far through the day you are, in the form of a progress bar. You might want to configure it to show how far you are through your waking hours, or you might want to use it to show how far you are through your work or school day.
|
||||
|
||||
## Shortcuts
|
||||
|
||||
There are generally a few apps that the user uses far more frequently than the others. For example, they might use a timer, alarm clock, and calculator every day, while everything else (such as the settings app) gets used only occasionally. This clock has space for 8 apps in the bottom half of the screen only one tap away, avoiding the need to wait for the launcher to open and then scroll through it. Tapping the top of the watch opens the launcher, eliminating the need for the button (which still opens the launcher due to bangle.js conventions). There is also handling for left, right, and vertical swipes. A vertical swipe by default opens the messages app, mimicking mobile operating systems which use a swipe down to view the notification shade.
|
||||
|
||||
## Configurability
|
||||
|
||||
Displaying the seconds allows for more precise timing, but waking up the CPU to refresh the display more often consumes battery. The user can enable or disable them completely, but can also configure them to be enabled or disabled automatically based on some hueristics:
|
||||
|
||||
* They can be hidden while the display is locked, if the user expects to unlock their watch when they need the seconds.
|
||||
* They can be hidden when the battery is too low, to make the last portion of the battery last a little bit longer.
|
||||
* They can be hidden during a period of time such as when the user is asleep and therefore unlikely to need very much precision.
|
||||
|
||||
The date format can be changed.
|
||||
|
||||
As described earlier, the contents of the bottom row when locked can be changed.
|
||||
|
||||
The 8 tap-based shortcuts on the bottom and the 3 swipe-based shortcuts can be changed to nothing, the launcher, or any app on the watch.
|
||||
|
||||
The start and end time of the day progress bar can be changed. It can be enabled or disabled separately when the watch is locked and unlocked. The color can be changed. The time when it resets from full to empty can be changed.
|
||||
|
||||
When the battery is below a defined point, the watch's color can change to another chosen color to help the user notice that the battery is low.
|
|
@ -0,0 +1,405 @@
|
|||
const SETTINGS_FILE = "infoclk.json";
|
||||
const FONT = require('infoclk-font.js');
|
||||
|
||||
const storage = require("Storage");
|
||||
const locale = require("locale");
|
||||
const weather = require('weather');
|
||||
|
||||
let config = Object.assign({
|
||||
seconds: {
|
||||
// Displaying the seconds can reduce battery life because the CPU must wake up more often to update the display.
|
||||
// The seconds will be shown unless one of these conditions is enabled here, and currently true.
|
||||
hideLocked: false, // Hide the seconds when the display is locked.
|
||||
hideBattery: 20, // Hide the seconds when the battery is at or below a defined percentage.
|
||||
hideTime: true, // Hide the seconds when between a certain period of time. Useful for when you are sleeping and don't need the seconds
|
||||
hideStart: 2200, // The time when the seconds are hidden: first 2 digits are hours on a 24 hour clock, last 2 are minutes
|
||||
hideEnd: 700, // The time when the seconds are shown again
|
||||
hideAlways: false, // Always hide (never show) the seconds
|
||||
},
|
||||
|
||||
date: {
|
||||
// Settings related to the display of the date
|
||||
mmdd: true, // If true, display the month first. If false, display the date first.
|
||||
separator: '-', // The character that goes between the month and date
|
||||
monthName: false, // If false, display the month as a number. If true, display the name.
|
||||
monthFullName: false, // If displaying the name: If false, display an abbreviation. If true, display a full name.
|
||||
dayFullName: false, // If false, display the day of the week's abbreviation. If true, display the full name.
|
||||
},
|
||||
|
||||
bottomLocked: {
|
||||
display: 'weather' // What to display in the bottom row when locked:
|
||||
// 'weather': The current temperature and weather description
|
||||
// 'steps': Step count
|
||||
// 'health': Step count and bpm
|
||||
// 'progress': Day progress bar
|
||||
// false: Nothing
|
||||
},
|
||||
|
||||
shortcuts: [
|
||||
//8 shortcuts, displayed in the bottom half of the screen (2 rows of 4 shortcuts) when unlocked
|
||||
// false = no shortcut
|
||||
// '#LAUNCHER' = open the launcher
|
||||
// any other string = name of app to open
|
||||
'stlap', 'keytimer', 'pomoplus', 'alarm',
|
||||
'rpnsci', 'calendar', 'torch', 'weather'
|
||||
],
|
||||
|
||||
swipe: {
|
||||
// 3 shortcuts to launch upon swiping:
|
||||
// false = no shortcut
|
||||
// '#LAUNCHER' = open the launcher
|
||||
// any other string = name of app to open
|
||||
up: 'messages', // Swipe up or swipe down, due to limitation of event handler
|
||||
left: '#LAUNCHER',
|
||||
right: '#LAUNCHER',
|
||||
},
|
||||
|
||||
dayProgress: {
|
||||
// A progress bar representing how far through the day you are
|
||||
enabledLocked: true, // Whether this bar is enabled when the watch is locked
|
||||
enabledUnlocked: false, // Whether the bar is enabled when the watch is unlocked
|
||||
color: [0, 0, 1], // The color of the bar
|
||||
start: 700, // The time of day that the bar starts filling
|
||||
end: 2200, // The time of day that the bar becomes full
|
||||
reset: 300 // The time of day when the progress bar resets from full to empty
|
||||
},
|
||||
|
||||
lowBattColor: {
|
||||
// The text can change color to indicate that the battery is low
|
||||
level: 20, // The percentage where this happens
|
||||
color: [1, 0, 0] // The color that the text changes to
|
||||
}
|
||||
}, storage.readJSON(SETTINGS_FILE));
|
||||
|
||||
// Return whether the given time (as a date object) is between start and end (as a number where the first 2 digits are hours on a 24 hour clock and the last 2 are minutes), with end time wrapping to next day if necessary
|
||||
function timeInRange(start, time, end) {
|
||||
|
||||
// Convert the given date object to a time number
|
||||
let timeNumber = time.getHours() * 100 + time.getMinutes();
|
||||
|
||||
// Normalize to prevent the numbers from wrapping around at midnight
|
||||
if (end <= start) {
|
||||
end += 2400;
|
||||
if (timeNumber < start) timeNumber += 2400;
|
||||
}
|
||||
|
||||
return start <= timeNumber && timeNumber <= end;
|
||||
}
|
||||
|
||||
// Return whether settings should be displayed based on the user's configuration
|
||||
function shouldDisplaySeconds(now) {
|
||||
return !(
|
||||
(config.seconds.hideAlways) ||
|
||||
(config.seconds.hideLocked && Bangle.isLocked()) ||
|
||||
(E.getBattery() <= config.seconds.hideBattery) ||
|
||||
(config.seconds.hideTime && timeInRange(config.seconds.hideStart, now, config.seconds.hideEnd))
|
||||
);
|
||||
}
|
||||
|
||||
// Determine the font size needed to fit a string of the given length widthin maxWidth number of pixels, clamped between minSize and maxSize
|
||||
function getFontSize(length, maxWidth, minSize, maxSize) {
|
||||
let size = Math.floor(maxWidth / length); //Number of pixels of width available to character
|
||||
size *= (20 / 12); //Convert to height, assuming 20 pixels of height for every 12 of width
|
||||
|
||||
// Clamp to within range
|
||||
if (size < minSize) return minSize;
|
||||
else if (size > maxSize) return maxSize;
|
||||
else return Math.floor(size);
|
||||
}
|
||||
|
||||
// Get the current day of the week according to user settings
|
||||
function getDayString(now) {
|
||||
if (config.date.dayFullName) return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][now.getDay()];
|
||||
else return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][now.getDay()];
|
||||
}
|
||||
|
||||
// Pad a number with zeros to be the given number of digits
|
||||
function pad(number, digits) {
|
||||
let result = '' + number;
|
||||
while (result.length < digits) result = '0' + result;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get the current date formatted according to the user settings
|
||||
function getDateString(now) {
|
||||
let month;
|
||||
if (!config.date.monthName) month = pad(now.getMonth() + 1, 2);
|
||||
else if (config.date.monthFullName) month = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][now.getMonth()];
|
||||
else month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][now.getMonth()];
|
||||
|
||||
if (config.date.mmdd) return `${month}${config.date.separator}${pad(now.getDate(), 2)}`;
|
||||
else return `${pad(now.getDate(), 2)}${config.date.separator}${month}`;
|
||||
}
|
||||
|
||||
// Get a floating point number from 0 to 1 representing how far between the user-defined start and end points we are
|
||||
function getDayProgress(now) {
|
||||
let start = config.dayProgress.start;
|
||||
let current = now.getHours() * 100 + now.getMinutes();
|
||||
let end = config.dayProgress.end;
|
||||
let reset = config.dayProgress.reset;
|
||||
|
||||
// Normalize
|
||||
if (end <= start) end += 2400;
|
||||
if (current < start) current += 2400;
|
||||
if (reset < start) reset += 2400;
|
||||
|
||||
// Convert an hhmm number into a floating-point hours
|
||||
function toDecimalHours(time) {
|
||||
let hours = Math.floor(time / 100);
|
||||
let minutes = time % 100;
|
||||
|
||||
return hours + (minutes / 60);
|
||||
}
|
||||
|
||||
start = toDecimalHours(start);
|
||||
current = toDecimalHours(current);
|
||||
end = toDecimalHours(end);
|
||||
reset = toDecimalHours(reset);
|
||||
|
||||
let progress = (current - start) / (end - start);
|
||||
|
||||
if (progress < 0 || progress > 1) {
|
||||
if (current < reset) return 1;
|
||||
else return 0;
|
||||
} else {
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
|
||||
// Get a Gadgetbridge weather string
|
||||
function getWeatherString() {
|
||||
let current = weather.get();
|
||||
if (current) return locale.temp(current.temp - 273.15) + ', ' + current.txt;
|
||||
else return 'Weather unknown!';
|
||||
}
|
||||
|
||||
// Get a second weather row showing humidity, wind speed, and wind direction
|
||||
function getWeatherRow2() {
|
||||
let current = weather.get();
|
||||
if (current) return `${current.hum}%, ${locale.speed(current.wind)} ${current.wrose}`;
|
||||
else return 'Check Gadgetbridge';
|
||||
}
|
||||
|
||||
// Get a step string
|
||||
function getStepsString() {
|
||||
return '' + Bangle.getHealthStatus('day').steps + ' steps';
|
||||
}
|
||||
|
||||
// Get a health string including daily steps and recent bpm
|
||||
function getHealthString() {
|
||||
return `${Bangle.getHealthStatus('day').steps} steps ${Bangle.getHealthStatus('last').bpm} bpm`;
|
||||
}
|
||||
|
||||
// Set the next timeout to draw the screen
|
||||
let drawTimeout;
|
||||
function setNextDrawTimeout() {
|
||||
if (drawTimeout) {
|
||||
clearTimeout(drawTimeout);
|
||||
drawTimeout = undefined;
|
||||
}
|
||||
|
||||
let time;
|
||||
let now = new Date();
|
||||
if (shouldDisplaySeconds(now)) time = 1000 - (now.getTime() % 1000);
|
||||
else time = 60000 - (now.getTime() % 60000);
|
||||
|
||||
drawTimeout = setTimeout(draw, time);
|
||||
}
|
||||
|
||||
|
||||
const DIGIT_WIDTH = 40; // How much width is allocated for each digit, 37 pixels + 3 pixels of space (which will go off of the screen on the right edge)
|
||||
const COLON_WIDTH = 19; // How much width is allocated for the colon, 16 pixels + 3 pixels of space
|
||||
const HHMM_TOP = 27; // 24 pixels for widgets + 3 pixels of space
|
||||
const DIGIT_HEIGHT = 64; // How tall the digits are
|
||||
|
||||
const SECONDS_TOP = HHMM_TOP + DIGIT_HEIGHT + 3; // The top edge of the seconds, top of hours and minutes + digit height + space
|
||||
const SECONDS_LEFT = 2 * DIGIT_WIDTH + COLON_WIDTH; // The left edge of the seconds: displayed after 2 digits and the colon
|
||||
const DATE_LETTER_HEIGHT = DIGIT_HEIGHT / 2; // Each letter of the day of week and date will be half the height of the time digits
|
||||
|
||||
const DATE_CENTER_X = SECONDS_LEFT / 2; // Day of week and date will be centered between left edge of screen and where seconds start
|
||||
const DOW_CENTER_Y = SECONDS_TOP + (DATE_LETTER_HEIGHT / 2); // Day of week will be the top row
|
||||
const DATE_CENTER_Y = DOW_CENTER_Y + DATE_LETTER_HEIGHT; // Date will be the bottom row
|
||||
const DOW_DATE_CENTER_Y = SECONDS_TOP + (DIGIT_HEIGHT / 2); // When displaying both on one row, center it
|
||||
const BOTTOM_CENTER_Y = ((SECONDS_TOP + DIGIT_HEIGHT + 3) + g.getHeight()) / 2;
|
||||
|
||||
// Draw the clock
|
||||
function draw() {
|
||||
//Prepare to draw
|
||||
g.reset()
|
||||
.setFontAlign(0, 0);
|
||||
|
||||
if (E.getBattery() <= config.lowBattColor.level) {
|
||||
let color = config.lowBattColor.color;
|
||||
g.setColor(color[0], color[1], color[2]);
|
||||
}
|
||||
now = new Date();
|
||||
|
||||
if (Bangle.isLocked()) { //When the watch is locked
|
||||
g.clearRect(0, 24, g.getWidth(), g.getHeight());
|
||||
|
||||
//Draw the hours and minutes
|
||||
let x = 0;
|
||||
|
||||
for (let digit of locale.time(now, 1)) { //apparently this is how you get an hh:mm time string adjusting for the user's 12/24 hour preference
|
||||
if (digit != ' ') g.drawImage(FONT[digit], x, HHMM_TOP);
|
||||
if (digit == ':') x += COLON_WIDTH;
|
||||
else x += DIGIT_WIDTH;
|
||||
}
|
||||
if (storage.readJSON('setting.json')['12hour']) g.drawImage(FONT[(now.getHours() < 12) ? 'am' : 'pm'], 0, HHMM_TOP);
|
||||
|
||||
//Draw the seconds if necessary
|
||||
if (shouldDisplaySeconds(now)) {
|
||||
let tens = Math.floor(now.getSeconds() / 10);
|
||||
let ones = now.getSeconds() % 10;
|
||||
g.drawImage(FONT[tens], SECONDS_LEFT, SECONDS_TOP)
|
||||
.drawImage(FONT[ones], SECONDS_LEFT + DIGIT_WIDTH, SECONDS_TOP);
|
||||
|
||||
// Draw the day of week and date assuming the seconds are displayed
|
||||
|
||||
g.setFont('Vector', getFontSize(getDayString(now).length, SECONDS_LEFT, 6, DATE_LETTER_HEIGHT))
|
||||
.drawString(getDayString(now), DATE_CENTER_X, DOW_CENTER_Y)
|
||||
.setFont('Vector', getFontSize(getDateString(now).length, SECONDS_LEFT, 6, DATE_LETTER_HEIGHT))
|
||||
.drawString(getDateString(now), DATE_CENTER_X, DATE_CENTER_Y);
|
||||
|
||||
} else {
|
||||
//Draw the day of week and date without the seconds
|
||||
|
||||
let string = getDayString(now) + ' ' + getDateString(now);
|
||||
g.setFont('Vector', getFontSize(string.length, g.getWidth(), 6, DATE_LETTER_HEIGHT))
|
||||
.drawString(string, g.getWidth() / 2, DOW_DATE_CENTER_Y);
|
||||
}
|
||||
|
||||
// Draw the bottom area
|
||||
if (config.bottomLocked.display == 'progress') {
|
||||
let color = config.dayProgress.color;
|
||||
g.setColor(color[0], color[1], color[2])
|
||||
.fillRect(0, SECONDS_TOP + DIGIT_HEIGHT + 3, g.getWidth() * getDayProgress(now), g.getHeight());
|
||||
} else {
|
||||
let bottomString;
|
||||
|
||||
if (config.bottomLocked.display == 'weather') bottomString = getWeatherString();
|
||||
else if (config.bottomLocked.display == 'steps') bottomString = getStepsString();
|
||||
else if (config.bottomLocked.display == 'health') bottomString = getHealthString();
|
||||
else bottomString = ' ';
|
||||
|
||||
g.setFont('Vector', getFontSize(bottomString.length, 176, 6, g.getHeight() - (SECONDS_TOP + DIGIT_HEIGHT + 3)))
|
||||
.drawString(bottomString, g.getWidth() / 2, BOTTOM_CENTER_Y);
|
||||
}
|
||||
|
||||
// Draw the day progress bar between the rows if necessary
|
||||
if (config.dayProgress.enabledLocked && config.bottomLocked.display != 'progress') {
|
||||
let color = config.dayProgress.color;
|
||||
g.setColor(color[0], color[1], color[2])
|
||||
.fillRect(0, HHMM_TOP + DIGIT_HEIGHT, g.getWidth() * getDayProgress(now), SECONDS_TOP);
|
||||
}
|
||||
} else {
|
||||
|
||||
//If the watch is unlocked
|
||||
g.clearRect(0, 24, g.getWidth(), g.getHeight() / 2);
|
||||
rows = [
|
||||
`${getDayString(now)} ${getDateString(now)} ${locale.time(now, 1)}`,
|
||||
getHealthString(),
|
||||
getWeatherString(),
|
||||
getWeatherRow2()
|
||||
];
|
||||
if (shouldDisplaySeconds(now)) rows[0] += ':' + pad(now.getSeconds(), 2);
|
||||
if (storage.readJSON('setting.json')['12hour']) rows[0] += ((now.getHours() < 12) ? ' AM' : ' PM');
|
||||
|
||||
let maxHeight = ((g.getHeight() / 2) - HHMM_TOP) / (config.dayProgress.enabledUnlocked ? (rows.length + 1) : rows.length);
|
||||
|
||||
let y = HHMM_TOP + maxHeight / 2;
|
||||
for (let row of rows) {
|
||||
let size = getFontSize(row.length, g.getWidth(), 6, maxHeight);
|
||||
g.setFont('Vector', size)
|
||||
.drawString(row, g.getWidth() / 2, y);
|
||||
y += maxHeight;
|
||||
}
|
||||
|
||||
if (config.dayProgress.enabledUnlocked) {
|
||||
let color = config.dayProgress.color;
|
||||
g.setColor(color[0], color[1], color[2])
|
||||
.fillRect(0, y - maxHeight / 2, 176 * getDayProgress(now), y + maxHeight / 2);
|
||||
}
|
||||
}
|
||||
|
||||
setNextDrawTimeout();
|
||||
}
|
||||
|
||||
// Draw the icons. This is done separately from the main draw routine to avoid having to scale and draw a bunch of images repeatedly.
|
||||
function drawIcons() {
|
||||
g.reset().clearRect(0, 24, g.getWidth(), g.getHeight());
|
||||
for (let i = 0; i < 8; i++) {
|
||||
let x = [0, 44, 88, 132, 0, 44, 88, 132][i];
|
||||
let y = [88, 88, 88, 88, 132, 132, 132, 132][i];
|
||||
let appId = config.shortcuts[i];
|
||||
let appInfo = storage.readJSON(appId + '.info', 1);
|
||||
if (!appInfo) continue;
|
||||
icon = storage.read(appInfo.icon);
|
||||
g.drawImage(icon, x, y, {
|
||||
scale: 0.916666666667
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
weather.on("update", draw);
|
||||
Bangle.on("step", draw);
|
||||
Bangle.on('lock', locked => {
|
||||
//If the watch is unlocked, draw the icons
|
||||
if (!locked) drawIcons();
|
||||
draw();
|
||||
});
|
||||
|
||||
// Show launcher when middle button pressed
|
||||
Bangle.setUI("clock");
|
||||
// Load widgets
|
||||
Bangle.loadWidgets();
|
||||
Bangle.drawWidgets();
|
||||
|
||||
// Launch an app given the current ID. Handles special cases:
|
||||
// false: Do nothing
|
||||
// '#LAUNCHER': Open the launcher
|
||||
// nonexistent app: Do nothing
|
||||
function launch(appId) {
|
||||
if (appId == false) return;
|
||||
else if (appId == '#LAUNCHER') {
|
||||
Bangle.buzz();
|
||||
Bangle.showLauncher();
|
||||
} else {
|
||||
let appInfo = storage.readJSON(appId + '.info', 1);
|
||||
if (appInfo) {
|
||||
Bangle.buzz();
|
||||
load(appInfo.src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Set up touch to launch the selected app
|
||||
Bangle.on('touch', function (button, xy) {
|
||||
let x = Math.floor(xy.x / 44);
|
||||
if (x < 0) x = 0;
|
||||
else if (x > 3) x = 3;
|
||||
|
||||
let y = Math.floor(xy.y / 44);
|
||||
if (y < 0) y = -1;
|
||||
else if (y > 3) y = 1;
|
||||
else y -= 2;
|
||||
|
||||
if (y < 0) {
|
||||
Bangle.buzz();
|
||||
Bangle.showLauncher();
|
||||
} else {
|
||||
let i = 4 * y + x;
|
||||
launch(config.shortcuts[i]);
|
||||
}
|
||||
});
|
||||
|
||||
//Set up swipe handler
|
||||
Bangle.on('swipe', function (direction) {
|
||||
if (direction == -1) launch(config.swipe.left);
|
||||
else if (direction == 0) launch(config.swipe.up);
|
||||
else launch(config.swipe.right);
|
||||
});
|
||||
|
||||
if (!Bangle.isLocked()) drawIcons();
|
||||
|
||||
draw();
|
|
@ -0,0 +1,23 @@
|
|||
const heatshrink = require("heatshrink")
|
||||
|
||||
function decompress(string) {
|
||||
return heatshrink.decompress(atob(string))
|
||||
}
|
||||
|
||||
exports = {
|
||||
'0': decompress("ktAwIEB////EAj4EB+EDAYP/8E/AgWDAYX+CIX/+IDC//PBoYIDAAvwgEHAgOAgAnB/kAgIvCgEPAgJCBv5CCHwXAI4X+KAYk/En4kmAA4qBAAP7BAePAYX4BofBAYX8F4Q+BEwRHBIQI5BA"),
|
||||
'1': decompress("ktAwIGDj/4AgX/4ADBg/+BAU/+ADBgP/wAEBh/8BoV/8ADBgf/En4k/En4k/EgQ="),
|
||||
'2': decompress("ktAwMA/4AB/EHAgXwn4EC8IDC/+PAYX+v4EC+YND74NDBAYAE4A0Bg/+HIU/+ADBgP/wAEBh/8BoV/8ADBgf/BAUf/AECElQdBPA2HAYX8OYfHBAYRD8Z3Dj6TG/kPPYZm4EiwAHO4f7BAfPfI/xBoaTEPAfgQwY"),
|
||||
'3': decompress("ktAwMA/4AB/EHAgXwn4EC8IDC/+PAYX+v4EC+YND74NDBAYAE4A0Bg/+HIU/+ADBgP/wAEBh/8BoV/8ADBgf/BAUf/AECElJWIAEpu/EhpgS34DC/IID54DC/l/AgXDAYX4j57DA"),
|
||||
'4': decompress("ktAwMA//AgEf//+BYP///wgEHAgOAgE///8gEBBAPggEPAgIWBv///EAgYIBEn4kXABf9AgfnAgY4BAAP4BAfDAYX+EwfwIQRRCJIJRBJIRRBJIQICj5RBJIRRBJIJRCNwJRBNwQk/Ei4A=="),
|
||||
'5': decompress("ktAwIEB/4AB/EfAgXDAYX+n4EC+YDC/+fAYX9BAfvAgYAJ+AwBgP/wAEBh/8H4V/8ADBgf/BAUf/AEC//AAYMH/wICn4kpPYUPAgXgv4EC4JfDg4DC/iFD8ANDwaTDCQfwEoZ2/EhrXNAAm/AYX5BAfPQoaTD4ahDj57DA=="),
|
||||
'6': decompress("ktAwIEB/4AB/EfAgXDAYX+n4EC+YDC/+fAYX9BAfvAgYAJ+AwBgP/wAEBh/8H4V/8ADBgf/BAUf/AEC//AAYMH/wICn4kpPYUPAgXgv6AG/6JD/gID84ED358NJIIsCKIQ0BKIRJCFgJJCSYcHAgJuBXYJuBKIQkpAA58D/YIDx6PDBofBQoYvCHwImCI4KUCwA="),
|
||||
'7': decompress("ktAwMA/4AB/EHAgXwn4EC8IDC/+PAYX+v4EC+YND74NDBAYAE4A0Bg/+HIU/+ADBgP/wAEBh/8BoV/8ADBgf/BAUf/AECEn4k/En4kVA"),
|
||||
'8': decompress("ktAwIEB////EAj4EB+EDAYP/8E/AgWDAYX+CIX/+IDC//PBoYIDAAvwgEHAgOAgAnB/kAgIvCgEPAgJCBv5CCHwXAI4X+KAYkpAFpu/EhwAHFQIAB/YIDx4DC/AND4IDC/ieD4AmCI4JCBHIIA=="),
|
||||
'9': decompress("ktAwIEB////EAj4EB+EDAYP/8E/AgWDAYX+CIX/+IDC//PBoYIDAAvwgEHAgOAgAnB/kAgIvCgEPAgJCBv5CCHwXAI4X+KAYkpABf9AgfnAgaFD/AID4Z8DEwfwIQRRCJIJRBJIRRBJIQICj5RBJIRRBJIJRCNwJRBNwQkoPhoAE34DC/L0H/iwBQAv4WAJ7CA=="),
|
||||
|
||||
':': decompress("iFAwITQg/gj/4n/8v/+AIP/ABQPDCoIZBDoJTfH94A=="),
|
||||
|
||||
'am': decompress("jFAwIEBngCEvwCH/4CFwEBAQkD//AgfnAQcH4fgAQsPwPwAQf/+Ef//4AQn8n0AvgCCHQN+vkAnwCC/EAj4CF+EAh4CCNIoLFC4v8gE/AQv+gF/AQpwB/4CDwICG+/D94CD8/v+fn54CC+P/x4CF+H/IgICFvwCEngCD"),
|
||||
'pm': decompress("jFAwMAn///l///+/4AE+EAh4CaEYoABFgX8BwMAAUwAFIIv4gEfAQX8OYICF/0Av4CF/8AKQICCwICG+/D94CD8/v+fn54CC+P/x4CF+H/IgICFvwCEngCDA")
|
||||
}
|
After Width: | Height: | Size: 360 B |
After Width: | Height: | Size: 216 B |
After Width: | Height: | Size: 290 B |
After Width: | Height: | Size: 183 B |
After Width: | Height: | Size: 305 B |
After Width: | Height: | Size: 270 B |
After Width: | Height: | Size: 247 B |
After Width: | Height: | Size: 302 B |
After Width: | Height: | Size: 309 B |
After Width: | Height: | Size: 227 B |
After Width: | Height: | Size: 309 B |
After Width: | Height: | Size: 319 B |
After Width: | Height: | Size: 327 B |
|
@ -0,0 +1 @@
|
|||
require("heatshrink").decompress(atob("mEwgOAA4YFS/4AKEf5BlABcAjAgBjAfBAuhH/Apo"))
|
After Width: | Height: | Size: 249 B |
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"id": "infoclk",
|
||||
"name": "Informational clock",
|
||||
"version": "0.08",
|
||||
"description": "A configurable clock with extra info and shortcuts when unlocked, but large time when locked",
|
||||
"readme": "README.md",
|
||||
"icon": "icon.png",
|
||||
"type": "clock",
|
||||
"tags": "clock",
|
||||
"supports": [
|
||||
"BANGLEJS2"
|
||||
],
|
||||
"allow_emulator": true,
|
||||
"storage": [
|
||||
{
|
||||
"name": "infoclk.app.js",
|
||||
"url": "app.js"
|
||||
},
|
||||
{
|
||||
"name": "infoclk.settings.js",
|
||||
"url": "settings.js"
|
||||
},
|
||||
{
|
||||
"name": "infoclk-font.js",
|
||||
"url": "font.js"
|
||||
},
|
||||
{
|
||||
"name": "infoclk.img",
|
||||
"url": "icon.js",
|
||||
"evaluate": true
|
||||
}
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"name": "infoclk.json"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,571 @@
|
|||
(function (back) {
|
||||
const SETTINGS_FILE = "infoclk.json";
|
||||
const storage = require('Storage');
|
||||
|
||||
let config = Object.assign({
|
||||
seconds: {
|
||||
// Displaying the seconds can reduce battery life because the CPU must wake up more often to update the display.
|
||||
// The seconds will be shown unless one of these conditions is enabled here, and currently true.
|
||||
hideLocked: false, // Hide the seconds when the display is locked.
|
||||
hideBattery: 20, // Hide the seconds when the battery is at or below a defined percentage.
|
||||
hideTime: true, // Hide the seconds when between a certain period of time. Useful for when you are sleeping and don't need the seconds
|
||||
hideStart: 2200, // The time when the seconds are hidden: first 2 digits are hours on a 24 hour clock, last 2 are minutes
|
||||
hideEnd: 700, // The time when the seconds are shown again
|
||||
hideAlways: false, // Always hide (never show) the seconds
|
||||
},
|
||||
|
||||
date: {
|
||||
// Settings related to the display of the date
|
||||
mmdd: true, // If true, display the month first. If false, display the date first.
|
||||
separator: '-', // The character that goes between the month and date
|
||||
monthName: false, // If false, display the month as a number. If true, display the name.
|
||||
monthFullName: false, // If displaying the name: If false, display an abbreviation. If true, display a full name.
|
||||
dayFullName: false, // If false, display the day of the week's abbreviation. If true, display the full name.
|
||||
},
|
||||
|
||||
bottomLocked: {
|
||||
display: 'weather' // What to display in the bottom row when locked:
|
||||
// 'weather': The current temperature and weather description
|
||||
// 'steps': Step count
|
||||
// 'health': Step count and bpm
|
||||
// 'progress': Day progress bar
|
||||
// false: Nothing
|
||||
},
|
||||
|
||||
shortcuts: [
|
||||
//8 shortcuts, displayed in the bottom half of the screen (2 rows of 4 shortcuts) when unlocked
|
||||
// false = no shortcut
|
||||
// '#LAUNCHER' = open the launcher
|
||||
// any other string = name of app to open
|
||||
'stlap', 'keytimer', 'pomoplus', 'alarm',
|
||||
'rpnsci', 'calendar', 'torch', 'weather'
|
||||
],
|
||||
|
||||
swipe: {
|
||||
// 3 shortcuts to launch upon swiping:
|
||||
// false = no shortcut
|
||||
// '#LAUNCHER' = open the launcher
|
||||
// any other string = name of app to open
|
||||
up: 'messages', // Swipe up or swipe down, due to limitation of event handler
|
||||
left: '#LAUNCHER',
|
||||
right: '#LAUNCHER',
|
||||
},
|
||||
|
||||
dayProgress: {
|
||||
// A progress bar representing how far through the day you are
|
||||
enabledLocked: true, // Whether this bar is enabled when the watch is locked
|
||||
enabledUnlocked: false, // Whether the bar is enabled when the watch is unlocked
|
||||
color: [0, 0, 1], // The color of the bar
|
||||
start: 700, // The time of day that the bar starts filling
|
||||
end: 2200, // The time of day that the bar becomes full
|
||||
reset: 300 // The time of day when the progress bar resets from full to empty
|
||||
},
|
||||
|
||||
lowBattColor: {
|
||||
// The text can change color to indicate that the battery is low
|
||||
level: 20, // The percentage where this happens
|
||||
color: [1, 0, 0] // The color that the text changes to
|
||||
}
|
||||
}, storage.readJSON(SETTINGS_FILE));
|
||||
|
||||
function saveSettings() {
|
||||
storage.writeJSON(SETTINGS_FILE, config);
|
||||
}
|
||||
|
||||
function hourToString(hour) {
|
||||
if (storage.readJSON('setting.json')['12hour']) {
|
||||
if (hour == 0) return '12 AM';
|
||||
else if (hour < 12) return `${hour} AM`;
|
||||
else if (hour == 12) return '12 PM';
|
||||
else return `${hour - 12} PM`;
|
||||
} else return '' + hour;
|
||||
}
|
||||
|
||||
// The menu for configuring when the seconds are shown
|
||||
function showSecondsMenu() {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Seconds display',
|
||||
'back': showMainMenu
|
||||
},
|
||||
'Show seconds': {
|
||||
value: !config.seconds.hideAlways,
|
||||
onchange: value => {
|
||||
config.seconds.hideAlways = !value;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'...unless locked': {
|
||||
value: config.seconds.hideLocked,
|
||||
onchange: value => {
|
||||
config.seconds.hideLocked = value;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'...unless battery below': {
|
||||
value: config.seconds.hideBattery,
|
||||
min: 0,
|
||||
max: 100,
|
||||
format: value => `${value}%`,
|
||||
onchange: value => {
|
||||
config.seconds.hideBattery = value;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'...unless between these 2 times...': () => {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Hide seconds between',
|
||||
'back': showSecondsMenu
|
||||
},
|
||||
'Enabled': {
|
||||
value: config.seconds.hideTime,
|
||||
onchange: value => {
|
||||
config.seconds.hideTime = value;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Start hour': {
|
||||
value: Math.floor(config.seconds.hideStart / 100),
|
||||
format: hourToString,
|
||||
min: 0,
|
||||
max: 23,
|
||||
wrap: true,
|
||||
onchange: hour => {
|
||||
minute = config.seconds.hideStart % 100;
|
||||
config.seconds.hideStart = (100 * hour) + minute;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Start minute': {
|
||||
value: config.seconds.hideStart % 100,
|
||||
min: 0,
|
||||
max: 59,
|
||||
wrap: true,
|
||||
onchange: minute => {
|
||||
hour = Math.floor(config.seconds.hideStart / 100);
|
||||
config.seconds.hideStart = (100 * hour) + minute;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'End hour': {
|
||||
value: Math.floor(config.seconds.hideEnd / 100),
|
||||
format: hourToString,
|
||||
min: 0,
|
||||
max: 23,
|
||||
wrap: true,
|
||||
onchange: hour => {
|
||||
minute = config.seconds.hideEnd % 100;
|
||||
config.seconds.hideEnd = (100 * hour) + minute;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'End minute': {
|
||||
value: config.seconds.hideEnd % 100,
|
||||
min: 0,
|
||||
max: 59,
|
||||
wrap: true,
|
||||
onchange: minute => {
|
||||
hour = Math.floor(config.seconds.hideEnd / 100);
|
||||
config.seconds.hideEnd = (100 * hour) + minute;
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Available month/date separators
|
||||
const SEPARATORS = [
|
||||
{ name: 'Slash', char: '/' },
|
||||
{ name: 'Dash', char: '-' },
|
||||
{ name: 'Space', char: ' ' },
|
||||
{ name: 'Comma', char: ',' },
|
||||
{ name: 'None', char: '' }
|
||||
];
|
||||
|
||||
// Available bottom row display options
|
||||
const BOTTOM_ROW_OPTIONS = [
|
||||
{ name: 'Weather', val: 'weather' },
|
||||
{ name: 'Step count', val: 'steps' },
|
||||
{ name: 'Steps + BPM', val: 'health' },
|
||||
{ name: 'Day progresss bar', val: 'progress' },
|
||||
{ name: 'Nothing', val: false }
|
||||
];
|
||||
|
||||
// The menu for configuring which apps have shortcut icons
|
||||
function showShortcutMenu() {
|
||||
//Builds the shortcut options
|
||||
let shortcutOptions = [
|
||||
{ name: 'Nothing', val: false },
|
||||
{ name: 'Launcher', val: '#LAUNCHER' },
|
||||
];
|
||||
|
||||
let infoFiles = storage.list(/\.info$/).sort((a, b) => {
|
||||
if (a.name < b.name) return -1;
|
||||
else if (a.name > b.name) return 1;
|
||||
else return 0;
|
||||
});
|
||||
for (let infoFile of infoFiles) {
|
||||
let appInfo = storage.readJSON(infoFile);
|
||||
if (appInfo.src) shortcutOptions.push({
|
||||
name: appInfo.name,
|
||||
val: appInfo.id
|
||||
});
|
||||
}
|
||||
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Shortcuts',
|
||||
'back': showMainMenu
|
||||
},
|
||||
'Top first': {
|
||||
value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[0]),
|
||||
format: value => shortcutOptions[value].name,
|
||||
min: 0,
|
||||
max: shortcutOptions.length - 1,
|
||||
wrap: false,
|
||||
onchange: value => {
|
||||
config.shortcuts[0] = shortcutOptions[value].val;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Top second': {
|
||||
value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[1]),
|
||||
format: value => shortcutOptions[value].name,
|
||||
min: 0,
|
||||
max: shortcutOptions.length - 1,
|
||||
wrap: false,
|
||||
onchange: value => {
|
||||
config.shortcuts[1] = shortcutOptions[value].val;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Top third': {
|
||||
value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[2]),
|
||||
format: value => shortcutOptions[value].name,
|
||||
min: 0,
|
||||
max: shortcutOptions.length - 1,
|
||||
wrap: false,
|
||||
onchange: value => {
|
||||
config.shortcuts[2] = shortcutOptions[value].val;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Top fourth': {
|
||||
value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[3]),
|
||||
format: value => shortcutOptions[value].name,
|
||||
min: 0,
|
||||
max: shortcutOptions.length - 1,
|
||||
wrap: false,
|
||||
onchange: value => {
|
||||
config.shortcuts[3] = shortcutOptions[value].val;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Bottom first': {
|
||||
value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[4]),
|
||||
format: value => shortcutOptions[value].name,
|
||||
min: 0,
|
||||
max: shortcutOptions.length - 1,
|
||||
wrap: false,
|
||||
onchange: value => {
|
||||
config.shortcuts[4] = shortcutOptions[value].val;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Bottom second': {
|
||||
value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[5]),
|
||||
format: value => shortcutOptions[value].name,
|
||||
min: 0,
|
||||
max: shortcutOptions.length - 1,
|
||||
wrap: false,
|
||||
onchange: value => {
|
||||
config.shortcuts[5] = shortcutOptions[value].val;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Bottom third': {
|
||||
value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[6]),
|
||||
format: value => shortcutOptions[value].name,
|
||||
min: 0,
|
||||
max: shortcutOptions.length - 1,
|
||||
wrap: false,
|
||||
onchange: value => {
|
||||
config.shortcuts[6] = shortcutOptions[value].val;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Bottom fourth': {
|
||||
value: shortcutOptions.map(item => item.val).indexOf(config.shortcuts[7]),
|
||||
format: value => shortcutOptions[value].name,
|
||||
min: 0,
|
||||
max: shortcutOptions.length - 1,
|
||||
wrap: false,
|
||||
onchange: value => {
|
||||
config.shortcuts[7] = shortcutOptions[value].val;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Swipe up': {
|
||||
value: shortcutOptions.map(item => item.val).indexOf(config.swipe.up),
|
||||
format: value => shortcutOptions[value].name,
|
||||
min: 0,
|
||||
max: shortcutOptions.length - 1,
|
||||
wrap: false,
|
||||
onchange: value => {
|
||||
config.swipe.up = shortcutOptions[value].val;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Swipe left': {
|
||||
value: shortcutOptions.map(item => item.val).indexOf(config.swipe.left),
|
||||
format: value => shortcutOptions[value].name,
|
||||
min: 0,
|
||||
max: shortcutOptions.length - 1,
|
||||
wrap: false,
|
||||
onchange: value => {
|
||||
config.swipe.left = shortcutOptions[value].val;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Swipe right': {
|
||||
value: shortcutOptions.map(item => item.val).indexOf(config.swipe.right),
|
||||
format: value => shortcutOptions[value].name,
|
||||
min: 0,
|
||||
max: shortcutOptions.length - 1,
|
||||
wrap: false,
|
||||
onchange: value => {
|
||||
config.swipe.right = shortcutOptions[value].val;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const COLOR_OPTIONS = [
|
||||
{ name: 'Black', val: [0, 0, 0] },
|
||||
{ name: 'Blue', val: [0, 0, 1] },
|
||||
{ name: 'Green', val: [0, 1, 0] },
|
||||
{ name: 'Cyan', val: [0, 1, 1] },
|
||||
{ name: 'Red', val: [1, 0, 0] },
|
||||
{ name: 'Magenta', val: [1, 0, 1] },
|
||||
{ name: 'Yellow', val: [1, 1, 0] },
|
||||
{ name: 'White', val: [1, 1, 1] }
|
||||
];
|
||||
|
||||
// Workaround for being unable to use == on arrays: convert them into strings
|
||||
function colorString(color) {
|
||||
return `${color[0]} ${color[1]} ${color[2]}`;
|
||||
}
|
||||
|
||||
//Shows the top level menu
|
||||
function showMainMenu() {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Informational Clock',
|
||||
'back': back
|
||||
},
|
||||
'Seconds display': showSecondsMenu,
|
||||
'Day of week format': {
|
||||
value: config.date.dayFullName,
|
||||
format: value => value ? 'Full name' : 'Abbreviation',
|
||||
onchange: value => {
|
||||
config.date.dayFullName = value;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Date format': () => {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Date format',
|
||||
'back': showMainMenu,
|
||||
},
|
||||
'Order': {
|
||||
value: config.date.mmdd,
|
||||
format: value => value ? 'Month first' : 'Date first',
|
||||
onchange: value => {
|
||||
config.date.mmdd = value;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Separator': {
|
||||
value: SEPARATORS.map(item => item.char).indexOf(config.date.separator),
|
||||
format: value => SEPARATORS[value].name,
|
||||
min: 0,
|
||||
max: SEPARATORS.length - 1,
|
||||
wrap: true,
|
||||
onchange: value => {
|
||||
config.date.separator = SEPARATORS[value].char;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Month format': {
|
||||
// 0 = number only
|
||||
// 1 = abbreviation
|
||||
// 2 = full name
|
||||
value: config.date.monthName ? (config.date.monthFullName ? 2 : 1) : 0,
|
||||
format: value => ['Number', 'Abbreviation', 'Full name'][value],
|
||||
min: 0,
|
||||
max: 2,
|
||||
wrap: true,
|
||||
onchange: value => {
|
||||
if (value == 0) config.date.monthName = false;
|
||||
else {
|
||||
config.date.monthName = true;
|
||||
config.date.monthFullName = (value == 2);
|
||||
}
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
'Bottom row': {
|
||||
value: BOTTOM_ROW_OPTIONS.map(item => item.val).indexOf(config.bottomLocked.display),
|
||||
format: value => BOTTOM_ROW_OPTIONS[value].name,
|
||||
min: 0,
|
||||
max: BOTTOM_ROW_OPTIONS.length - 1,
|
||||
wrap: true,
|
||||
onchange: value => {
|
||||
config.bottomLocked.display = BOTTOM_ROW_OPTIONS[value].val;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Shortcuts': showShortcutMenu,
|
||||
'Day progress': () => {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Day progress',
|
||||
'back': showMainMenu
|
||||
},
|
||||
'Enable while locked': {
|
||||
value: config.dayProgress.enabledLocked,
|
||||
onchange: value => {
|
||||
config.dayProgress.enableLocked = value;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Enable while unlocked': {
|
||||
value: config.dayProgress.enabledUnlocked,
|
||||
onchange: value => {
|
||||
config.dayProgress.enabledUnlocked = value;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Color': {
|
||||
value: COLOR_OPTIONS.map(item => colorString(item.val)).indexOf(colorString(config.dayProgress.color)),
|
||||
format: value => COLOR_OPTIONS[value].name,
|
||||
min: 0,
|
||||
max: COLOR_OPTIONS.length - 1,
|
||||
wrap: false,
|
||||
onchange: value => {
|
||||
config.dayProgress.color = COLOR_OPTIONS[value].val;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Start hour': {
|
||||
value: Math.floor(config.dayProgress.start / 100),
|
||||
format: hourToString,
|
||||
min: 0,
|
||||
max: 23,
|
||||
wrap: true,
|
||||
onchange: hour => {
|
||||
minute = config.dayProgress.start % 100;
|
||||
config.dayProgress.start = (100 * hour) + minute;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Start minute': {
|
||||
value: config.dayProgress.start % 100,
|
||||
min: 0,
|
||||
max: 59,
|
||||
wrap: true,
|
||||
onchange: minute => {
|
||||
hour = Math.floor(config.dayProgress.start / 100);
|
||||
config.dayProgress.start = (100 * hour) + minute;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'End hour': {
|
||||
value: Math.floor(config.dayProgress.end / 100),
|
||||
format: hourToString,
|
||||
min: 0,
|
||||
max: 23,
|
||||
wrap: true,
|
||||
onchange: hour => {
|
||||
minute = config.dayProgress.end % 100;
|
||||
config.dayProgress.end = (100 * hour) + minute;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'End minute': {
|
||||
value: config.dayProgress.end % 100,
|
||||
min: 0,
|
||||
max: 59,
|
||||
wrap: true,
|
||||
onchange: minute => {
|
||||
hour = Math.floor(config.dayProgress.end / 100);
|
||||
config.dayProgress.end = (100 * hour) + minute;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Reset hour': {
|
||||
value: Math.floor(config.dayProgress.reset / 100),
|
||||
format: hourToString,
|
||||
min: 0,
|
||||
max: 23,
|
||||
wrap: true,
|
||||
onchange: hour => {
|
||||
minute = config.dayProgress.reset % 100;
|
||||
config.dayProgress.reset = (100 * hour) + minute;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Reset minute': {
|
||||
value: config.dayProgress.reset % 100,
|
||||
min: 0,
|
||||
max: 59,
|
||||
wrap: true,
|
||||
onchange: minute => {
|
||||
hour = Math.floor(config.dayProgress.reset / 100);
|
||||
config.dayProgress.reset = (100 * hour) + minute;
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
'Low battery color': () => {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Low battery color',
|
||||
back: showMainMenu
|
||||
},
|
||||
'Low battery threshold': {
|
||||
value: config.lowBattColor.level,
|
||||
min: 0,
|
||||
max: 100,
|
||||
format: value => `${value}%`,
|
||||
onchange: value => {
|
||||
config.lowBattColor.level = value;
|
||||
saveSettings();
|
||||
}
|
||||
},
|
||||
'Color': {
|
||||
value: COLOR_OPTIONS.map(item => colorString(item.val)).indexOf(colorString(config.lowBattColor.color)),
|
||||
format: value => COLOR_OPTIONS[value].name,
|
||||
min: 0,
|
||||
max: COLOR_OPTIONS.length - 1,
|
||||
wrap: false,
|
||||
onchange: value => {
|
||||
config.lowBattColor.color = COLOR_OPTIONS[value].val;
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
showMainMenu();
|
||||
});
|
|
@ -0,0 +1,2 @@
|
|||
0.01: New app!
|
||||
0.02: Submitted to the app loader
|
|
@ -0,0 +1,27 @@
|
|||
Bangle.keytimer_ACTIVE = true;
|
||||
const common = require("keytimer-com.js");
|
||||
const storage = require("Storage");
|
||||
|
||||
const keypad = require("keytimer-keys.js");
|
||||
const timerView = require("keytimer-tview.js");
|
||||
|
||||
Bangle.loadWidgets();
|
||||
Bangle.drawWidgets();
|
||||
|
||||
//Save our state when the app is closed
|
||||
E.on('kill', () => {
|
||||
storage.writeJSON(common.STATE_PATH, common.state);
|
||||
});
|
||||
|
||||
//Handle touch here. I would implement these separately in each view, but I can't figure out how to clear the event listeners.
|
||||
Bangle.on('touch', (button, xy) => {
|
||||
if (common.state.wasRunning) timerView.touch(button, xy);
|
||||
else keypad.touch(button, xy);
|
||||
});
|
||||
|
||||
Bangle.on('swipe', dir => {
|
||||
if (!common.state.wasRunning) keypad.swipe(dir);
|
||||
});
|
||||
|
||||
if (common.state.wasRunning) timerView.show(common);
|
||||
else keypad.show(common);
|
|
@ -0,0 +1,11 @@
|
|||
const keytimer_common = require("keytimer-com.js");
|
||||
|
||||
//Only start the timeout if the timer is running
|
||||
if (keytimer_common.state.running) {
|
||||
setTimeout(() => {
|
||||
//Check now to avoid race condition
|
||||
if (Bangle.keytimer_ACTIVE === undefined) {
|
||||
load('keytimer-ring.js');
|
||||
}
|
||||
}, keytimer_common.getTimeLeft());
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
const storage = require("Storage");
|
||||
const heatshrink = require("heatshrink");
|
||||
|
||||
exports.STATE_PATH = "keytimer.state.json";
|
||||
|
||||
exports.BUTTON_ICONS = {
|
||||
play: heatshrink.decompress(atob("jEYwMAkAGBnACBnwCBn+AAQPgAQPwAQP8AQP/AQXAAQPwAQP8AQP+AQgICBwQUCEAn4FggyBHAQ+CIgQ")),
|
||||
pause: heatshrink.decompress(atob("jEYwMA/4BBAX4CEA")),
|
||||
reset: heatshrink.decompress(atob("jEYwMA/4BB/+BAQPDAQPnAQIAKv///0///8j///EP//wAQQICBwQUCEhgyCHAQ+CIgI="))
|
||||
};
|
||||
|
||||
//Store the minimal amount of information to be able to reconstruct the state of the timer at any given time.
|
||||
//This is necessary because it is necessary to write to flash to let the timer run in the background, so minimizing the writes is necessary.
|
||||
exports.STATE_DEFAULT = {
|
||||
wasRunning: false, //If the timer ever was running. Used to determine whether to display a reset button
|
||||
running: false, //Whether the timer is currently running
|
||||
startTime: 0, //When the timer was last started. Difference between this and now is how long timer has run continuously.
|
||||
pausedTime: 0, //When the timer was last paused. Used for expiration and displaying timer while paused.
|
||||
elapsedTime: 0, //How much time the timer had spent running before the current start time. Update on pause or user skipping stages.
|
||||
setTime: 0, //How long the user wants the timer to run for
|
||||
inputString: '0' //The string of numbers the user typed in.
|
||||
};
|
||||
exports.state = storage.readJSON(exports.STATE_PATH);
|
||||
if (!exports.state) {
|
||||
exports.state = exports.STATE_DEFAULT;
|
||||
}
|
||||
|
||||
//Get the number of milliseconds until the timer expires
|
||||
exports.getTimeLeft = function () {
|
||||
if (!exports.state.wasRunning) {
|
||||
//If the timer never ran, the time left is just the set time
|
||||
return exports.setTime
|
||||
} else if (exports.state.running) {
|
||||
//If the timer is running, the time left is current time - start time + preexisting time
|
||||
var runningTime = (new Date()).getTime() - exports.state.startTime + exports.state.elapsedTime;
|
||||
} else {
|
||||
//If the timer is not running, the same as above but use when the timer was paused instead of now.
|
||||
var runningTime = exports.state.pausedTime - exports.state.startTime + exports.state.elapsedTime;
|
||||
}
|
||||
|
||||
return exports.state.setTime - runningTime;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
require("heatshrink").decompress(atob("mEwwcAkmSpICOggRPpEACJ9AgESCJxMBhu27dtARVgCIMBCJpxDmwRL7ARDgwRL4CWECJaoFjYRJ2ARFgYRJwDNGCJFsb46SIRgQAFSRAQHSRCMEAAqSGRgoAFRhaSKRgySKRg6SIRhCSIRhCSICBqSCRhSSGRhY2FkARPhMkCJ9JkiONgECCIOQCJsSCIOSCJuSCIVACBcECIdICJYOBCIVJRhYRFSRSMBCIiSKBwgCCSRCMCCIqSIRgYCFRhYCFSQyMEAQqSGBw6SIRgySKRgtO4iSJBAmT23bOIqSCRgvtCINsSQ4aEndtCINt2KSGIggOBCIW2JQlARgZECCIhKEpBEGCIpKEA=="))
|
After Width: | Height: | Size: 414 B |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 2.8 KiB |
|
@ -0,0 +1,136 @@
|
|||
let common;
|
||||
|
||||
function inputStringToTime(inputString) {
|
||||
let number = parseInt(inputString);
|
||||
let hours = Math.floor(number / 10000);
|
||||
let minutes = Math.floor((number % 10000) / 100);
|
||||
let seconds = number % 100;
|
||||
|
||||
return 3600000 * hours +
|
||||
60000 * minutes +
|
||||
1000 * seconds;
|
||||
}
|
||||
|
||||
function pad(number) {
|
||||
return ('00' + parseInt(number)).slice(-2);
|
||||
}
|
||||
|
||||
function inputStringToDisplayString(inputString) {
|
||||
let number = parseInt(inputString);
|
||||
let hours = Math.floor(number / 10000);
|
||||
let minutes = Math.floor((number % 10000) / 100);
|
||||
let seconds = number % 100;
|
||||
|
||||
if (hours == 0 && minutes == 0) return '' + seconds;
|
||||
else if (hours == 0) return `${pad(minutes)}:${pad(seconds)}`;
|
||||
else return `${hours}:${pad(minutes)}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
class NumberButton {
|
||||
constructor(number) {
|
||||
this.label = '' + number;
|
||||
}
|
||||
|
||||
onclick() {
|
||||
if (common.state.inputString == '0') common.state.inputString = this.label;
|
||||
else common.state.inputString += this.label;
|
||||
common.state.setTime = inputStringToTime(common.state.inputString);
|
||||
feedback(true);
|
||||
updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
let ClearButton = {
|
||||
label: 'Clr',
|
||||
onclick: () => {
|
||||
common.state.inputString = '0';
|
||||
common.state.setTime = 0;
|
||||
updateDisplay();
|
||||
feedback(true);
|
||||
}
|
||||
};
|
||||
|
||||
let StartButton = {
|
||||
label: 'Go',
|
||||
onclick: () => {
|
||||
common.state.startTime = (new Date()).getTime();
|
||||
common.state.elapsedTime = 0;
|
||||
common.state.wasRunning = true;
|
||||
common.state.running = true;
|
||||
feedback(true);
|
||||
require('keytimer-tview.js').show(common);
|
||||
}
|
||||
};
|
||||
|
||||
const BUTTONS = [
|
||||
[new NumberButton(7), new NumberButton(8), new NumberButton(9), ClearButton],
|
||||
[new NumberButton(4), new NumberButton(5), new NumberButton(6), new NumberButton(0)],
|
||||
[new NumberButton(1), new NumberButton(2), new NumberButton(3), StartButton]
|
||||
];
|
||||
|
||||
function feedback(acceptable) {
|
||||
if (acceptable) Bangle.buzz(50, 0.5);
|
||||
else Bangle.buzz(200, 1);
|
||||
}
|
||||
|
||||
function drawButtons() {
|
||||
g.reset().clearRect(0, 44, 175, 175).setFont("Vector", 15).setFontAlign(0, 0);
|
||||
//Draw lines
|
||||
for (let x = 44; x <= 176; x += 44) {
|
||||
g.drawLine(x, 44, x, 175);
|
||||
}
|
||||
for (let y = 44; y <= 176; y += 44) {
|
||||
g.drawLine(0, y, 175, y);
|
||||
}
|
||||
for (let row = 0; row < 3; row++) {
|
||||
for (let col = 0; col < 4; col++) {
|
||||
g.drawString(BUTTONS[row][col].label, 22 + 44 * col, 66 + 44 * row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFontSize(length) {
|
||||
let size = Math.floor(176 / length); //Characters of width needed per pixel
|
||||
size *= (20 / 12); //Convert to height
|
||||
// Clamp to between 6 and 20
|
||||
if (size < 6) return 6;
|
||||
else if (size > 20) return 20;
|
||||
else return Math.floor(size);
|
||||
}
|
||||
|
||||
function updateDisplay() {
|
||||
let displayString = inputStringToDisplayString(common.state.inputString);
|
||||
g.clearRect(0, 24, 175, 43).setColor(storage.readJSON('setting.json').theme.fg2).setFontAlign(1, -1).setFont("Vector", getFontSize(displayString.length)).drawString(displayString, 176, 24);
|
||||
}
|
||||
|
||||
exports.show = function (callerCommon) {
|
||||
common = callerCommon;
|
||||
g.reset();
|
||||
drawButtons();
|
||||
updateDisplay();
|
||||
};
|
||||
|
||||
exports.touch = function (button, xy) {
|
||||
let row = Math.floor((xy.y - 44) / 44);
|
||||
let col = Math.floor(xy.x / 44);
|
||||
if (row < 0) return;
|
||||
if (row > 2) row = 2;
|
||||
if (col < 0) col = 0;
|
||||
if (col > 3) col = 3;
|
||||
|
||||
BUTTONS[row][col].onclick();
|
||||
};
|
||||
|
||||
exports.swipe = function (dir) {
|
||||
if (dir == -1) {
|
||||
if (common.state.inputString.length == 1) common.state.inputString = '0';
|
||||
else common.state.inputString = common.state.inputString.substring(0, common.state.inputString.length - 1);
|
||||
|
||||
common.state.setTime = inputStringToTime(common.state.inputString);
|
||||
|
||||
feedback(true);
|
||||
updateDisplay();
|
||||
} else if (dir == 0) {
|
||||
EnterButton.onclick();
|
||||
}
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"id": "keytimer",
|
||||
"name": "Keypad Timer",
|
||||
"version": "0.02",
|
||||
"description": "A timer with a keypad that runs in the background",
|
||||
"icon": "icon.png",
|
||||
"type": "app",
|
||||
"tags": "tools",
|
||||
"supports": [
|
||||
"BANGLEJS2"
|
||||
],
|
||||
"allow_emulator": true,
|
||||
"storage": [
|
||||
{
|
||||
"name": "keytimer.app.js",
|
||||
"url": "app.js"
|
||||
},
|
||||
{
|
||||
"name": "keytimer.img",
|
||||
"url": "icon.js",
|
||||
"evaluate": true
|
||||
},
|
||||
{
|
||||
"name": "keytimer.boot.js",
|
||||
"url": "boot.js"
|
||||
},
|
||||
{
|
||||
"name": "keytimer-com.js",
|
||||
"url": "common.js"
|
||||
},
|
||||
{
|
||||
"name": "keytimer-ring.js",
|
||||
"url": "ring.js"
|
||||
},
|
||||
{
|
||||
"name": "keytimer-keys.js",
|
||||
"url": "keypad.js"
|
||||
},
|
||||
{
|
||||
"name": "keytimer-tview.js",
|
||||
"url": "timerview.js"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
const common = require('keytimer-com.js');
|
||||
|
||||
Bangle.loadWidgets()
|
||||
Bangle.drawWidgets()
|
||||
|
||||
Bangle.setLocked(false);
|
||||
Bangle.setLCDPower(true);
|
||||
|
||||
let brightness = 0;
|
||||
|
||||
setInterval(() => {
|
||||
Bangle.buzz(200);
|
||||
Bangle.setLCDBrightness(1 - brightness);
|
||||
brightness = 1 - brightness;
|
||||
}, 400);
|
||||
Bangle.buzz(200);
|
||||
|
||||
function stopTimer() {
|
||||
common.state.wasRunning = false;
|
||||
common.state.running = false;
|
||||
require("Storage").writeJSON(common.STATE_PATH, common.state);
|
||||
}
|
||||
|
||||
E.showAlert("Timer expired!").then(() => {
|
||||
stopTimer();
|
||||
load();
|
||||
});
|
||||
E.on('kill', stopTimer);
|
|
@ -0,0 +1,107 @@
|
|||
let common;
|
||||
|
||||
function drawButtons() {
|
||||
//Draw the backdrop
|
||||
const BAR_TOP = g.getHeight() - 24;
|
||||
g.setColor(0, 0, 1).setFontAlign(0, -1)
|
||||
.clearRect(0, BAR_TOP, g.getWidth(), g.getHeight())
|
||||
.fillRect(0, BAR_TOP, g.getWidth(), g.getHeight())
|
||||
.setColor(1, 1, 1)
|
||||
.drawLine(g.getWidth() / 2, BAR_TOP, g.getWidth() / 2, g.getHeight())
|
||||
|
||||
//Draw the buttons
|
||||
.drawImage(common.BUTTON_ICONS.reset, g.getWidth() / 4, BAR_TOP);
|
||||
if (common.state.running) {
|
||||
g.drawImage(common.BUTTON_ICONS.pause, g.getWidth() * 3 / 4, BAR_TOP);
|
||||
} else {
|
||||
g.drawImage(common.BUTTON_ICONS.play, g.getWidth() * 3 / 4, BAR_TOP);
|
||||
}
|
||||
}
|
||||
|
||||
function drawTimer() {
|
||||
let timeLeft = common.getTimeLeft();
|
||||
g.reset()
|
||||
.setFontAlign(0, 0)
|
||||
.setFont("Vector", 36)
|
||||
.clearRect(0, 24, 176, 152)
|
||||
|
||||
//Draw the timer
|
||||
.drawString((() => {
|
||||
let hours = timeLeft / 3600000;
|
||||
let minutes = (timeLeft % 3600000) / 60000;
|
||||
let seconds = (timeLeft % 60000) / 1000;
|
||||
|
||||
function pad(number) {
|
||||
return ('00' + parseInt(number)).slice(-2);
|
||||
}
|
||||
|
||||
if (hours >= 1) return `${parseInt(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||
else return `${parseInt(minutes)}:${pad(seconds)}`;
|
||||
})(), g.getWidth() / 2, g.getHeight() / 2)
|
||||
|
||||
if (timeLeft <= 0) load('keytimer-ring.js');
|
||||
}
|
||||
|
||||
let timerInterval;
|
||||
|
||||
function setupTimerInterval() {
|
||||
if (timerInterval !== undefined) {
|
||||
clearInterval(timerInterval);
|
||||
}
|
||||
setTimeout(() => {
|
||||
timerInterval = setInterval(drawTimer, 1000);
|
||||
drawTimer();
|
||||
}, common.timeLeft % 1000);
|
||||
}
|
||||
|
||||
exports.show = function (callerCommon) {
|
||||
common = callerCommon;
|
||||
drawButtons();
|
||||
drawTimer();
|
||||
if (common.state.running) {
|
||||
setupTimerInterval();
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimerInterval() {
|
||||
if (timerInterval !== undefined) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
exports.touch = (button, xy) => {
|
||||
if (xy.y < 152) return;
|
||||
|
||||
if (button == 1) {
|
||||
//Reset the timer
|
||||
let setTime = common.state.setTime;
|
||||
let inputString = common.state.inputString;
|
||||
common.state = common.STATE_DEFAULT;
|
||||
common.state.setTime = setTime;
|
||||
common.state.inputString = inputString;
|
||||
clearTimerInterval();
|
||||
require('keytimer-keys.js').show(common);
|
||||
} else {
|
||||
if (common.state.running) {
|
||||
//Record the exact moment that we paused
|
||||
let now = (new Date()).getTime();
|
||||
common.state.pausedTime = now;
|
||||
|
||||
//Stop the timer
|
||||
common.state.running = false;
|
||||
clearTimerInterval();
|
||||
drawTimer();
|
||||
drawButtons();
|
||||
} else {
|
||||
//Start the timer and record when we started
|
||||
let now = (new Date()).getTime();
|
||||
common.state.elapsedTime += common.state.pausedTime - common.state.startTime;
|
||||
common.state.startTime = now;
|
||||
common.state.running = true;
|
||||
drawTimer();
|
||||
setupTimerInterval();
|
||||
drawButtons();
|
||||
}
|
||||
}
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
0.01: New app!
|
||||
0.02-0.04: Bug fixes
|
||||
0.05: Submitted to the app loader
|
|
@ -0,0 +1,157 @@
|
|||
Bangle.POMOPLUS_ACTIVE = true; //Prevent the boot code from running. To avoid having to reload on every interaction, we'll control the vibrations from here when the user is in the app.
|
||||
|
||||
const storage = require("Storage");
|
||||
const common = require("pomoplus-com.js");
|
||||
|
||||
//Expire the state if necessary
|
||||
if (
|
||||
common.settings.pausedTimerExpireTime != 0 &&
|
||||
!common.state.running &&
|
||||
(new Date()).getTime() - common.state.pausedTime > common.settings.pausedTimerExpireTime
|
||||
) {
|
||||
common.state = common.STATE_DEFAULT;
|
||||
}
|
||||
|
||||
function drawButtons() {
|
||||
//Draw the backdrop
|
||||
const BAR_TOP = g.getHeight() - 24;
|
||||
g.setColor(0, 0, 1).setFontAlign(0, -1)
|
||||
.clearRect(0, BAR_TOP, g.getWidth(), g.getHeight())
|
||||
.fillRect(0, BAR_TOP, g.getWidth(), g.getHeight())
|
||||
.setColor(1, 1, 1);
|
||||
|
||||
if (!common.state.wasRunning) { //If the timer was never started, only show a play button
|
||||
g.drawImage(common.BUTTON_ICONS.play, g.getWidth() / 2, BAR_TOP);
|
||||
} else {
|
||||
g.drawLine(g.getWidth() / 2, BAR_TOP, g.getWidth() / 2, g.getHeight());
|
||||
if (common.state.running) {
|
||||
g.drawImage(common.BUTTON_ICONS.pause, g.getWidth() / 4, BAR_TOP)
|
||||
.drawImage(common.BUTTON_ICONS.skip, g.getWidth() * 3 / 4, BAR_TOP);
|
||||
} else {
|
||||
g.drawImage(common.BUTTON_ICONS.reset, g.getWidth() / 4, BAR_TOP)
|
||||
.drawImage(common.BUTTON_ICONS.play, g.getWidth() * 3 / 4, BAR_TOP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawTimerAndMessage() {
|
||||
g.reset()
|
||||
.setFontAlign(0, 0)
|
||||
.setFont("Vector", 36)
|
||||
.clearRect(0, 24, 176, 152)
|
||||
|
||||
//Draw the timer
|
||||
.drawString((() => {
|
||||
let timeLeft = common.getTimeLeft();
|
||||
let hours = timeLeft / 3600000;
|
||||
let minutes = (timeLeft % 3600000) / 60000;
|
||||
let seconds = (timeLeft % 60000) / 1000;
|
||||
|
||||
function pad(number) {
|
||||
return ('00' + parseInt(number)).slice(-2);
|
||||
}
|
||||
|
||||
if (hours >= 1) return `${parseInt(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||
else return `${parseInt(minutes)}:${pad(seconds)}`;
|
||||
})(), g.getWidth() / 2, g.getHeight() / 2)
|
||||
|
||||
//Draw the phase label
|
||||
.setFont("Vector", 12)
|
||||
.drawString(((currentPhase, numShortBreaks) => {
|
||||
if (!common.state.wasRunning) return "Not started";
|
||||
else if (currentPhase == common.PHASE_WORKING) return `Work ${numShortBreaks + 1}/${common.settings.numShortBreaks + 1}`
|
||||
else if (currentPhase == common.PHASE_SHORT_BREAK) return `Short break ${numShortBreaks + 1}/${common.settings.numShortBreaks}`;
|
||||
else return "Long break!";
|
||||
})(common.state.phase, common.state.numShortBreaks),
|
||||
g.getWidth() / 2, g.getHeight() / 2 + 18);
|
||||
|
||||
//Update phase with vibation if needed
|
||||
if (common.getTimeLeft() <= 0) {
|
||||
common.nextPhase(true);
|
||||
}
|
||||
}
|
||||
|
||||
drawButtons();
|
||||
Bangle.on("touch", (button, xy) => {
|
||||
//If we support full touch and we're not touching the keys, ignore.
|
||||
//If we don't support full touch, we can't tell so just assume we are.
|
||||
if (xy !== undefined && xy.y <= g.getHeight() - 24) return;
|
||||
|
||||
if (!common.state.wasRunning) {
|
||||
//If we were never running, there is only one button: the start button
|
||||
let now = (new Date()).getTime();
|
||||
common.state = {
|
||||
wasRunning: true,
|
||||
running: true,
|
||||
startTime: now,
|
||||
pausedTime: now,
|
||||
elapsedTime: 0,
|
||||
phase: common.PHASE_WORKING,
|
||||
numShortBreaks: 0
|
||||
};
|
||||
setupTimerInterval();
|
||||
drawButtons();
|
||||
|
||||
} else if (common.state.running) {
|
||||
//If we are running, there are two buttons: pause and skip
|
||||
if (button == 1) {
|
||||
//Record the exact moment that we paused
|
||||
let now = (new Date()).getTime();
|
||||
common.state.pausedTime = now;
|
||||
|
||||
//Stop the timer
|
||||
common.state.running = false;
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = undefined;
|
||||
drawTimerAndMessage();
|
||||
drawButtons();
|
||||
|
||||
} else {
|
||||
common.nextPhase(false);
|
||||
}
|
||||
|
||||
} else {
|
||||
//If we are stopped, there are two buttons: Reset and continue
|
||||
if (button == 1) {
|
||||
//Reset the timer
|
||||
common.state = common.STATE_DEFAULT;
|
||||
drawTimerAndMessage();
|
||||
drawButtons();
|
||||
|
||||
} else {
|
||||
//Start the timer and record old elapsed time and when we started
|
||||
let now = (new Date()).getTime();
|
||||
common.state.elapsedTime += common.state.pausedTime - common.state.startTime;
|
||||
common.state.startTime = now;
|
||||
common.state.running = true;
|
||||
drawTimerAndMessage();
|
||||
setupTimerInterval();
|
||||
drawButtons();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let timerInterval;
|
||||
|
||||
function setupTimerInterval() {
|
||||
if (timerInterval !== undefined) {
|
||||
clearInterval(timerInterval);
|
||||
}
|
||||
setTimeout(() => {
|
||||
timerInterval = setInterval(drawTimerAndMessage, 1000);
|
||||
drawTimerAndMessage();
|
||||
}, common.timeLeft % 1000);
|
||||
}
|
||||
|
||||
drawTimerAndMessage();
|
||||
if (common.state.running) {
|
||||
setupTimerInterval();
|
||||
}
|
||||
|
||||
//Save our state when the app is closed
|
||||
E.on('kill', () => {
|
||||
storage.writeJSON(common.STATE_PATH, common.state);
|
||||
});
|
||||
|
||||
Bangle.loadWidgets();
|
||||
Bangle.drawWidgets();
|
|
@ -0,0 +1,19 @@
|
|||
const POMOPLUS_storage = require("Storage");
|
||||
const POMOPLUS_common = require("pomoplus-com.js");
|
||||
|
||||
function setNextTimeout() {
|
||||
setTimeout(() => {
|
||||
//Make sure that the pomoplus app isn't in the foreground. The pomoplus app handles the vibrations when it is in the foreground in order to avoid having to reload every time the user changes state. That means that when the app is in the foreground, we shouldn't do anything here.
|
||||
//We do this after the timer rather than before because the timer will start before the app executes.
|
||||
if (Bangle.POMOPLUS_ACTIVE === undefined) {
|
||||
POMOPLUS_common.nextPhase(true);
|
||||
setNextTimeout();
|
||||
POMOPLUS_storage.writeJSON(POMOPLUS_common.STATE_PATH, POMOPLUS_common.state)
|
||||
}
|
||||
}, POMOPLUS_common.getTimeLeft());
|
||||
}
|
||||
|
||||
//Only start the timeout if the timer is running
|
||||
if (POMOPLUS_common.state.running) {
|
||||
setNextTimeout();
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
const storage = require("Storage");
|
||||
const heatshrink = require("heatshrink");
|
||||
|
||||
exports.STATE_PATH = "pomoplus.state.json";
|
||||
exports.SETTINGS_PATH = "pomoplus.json";
|
||||
|
||||
exports.PHASE_WORKING = 0;
|
||||
exports.PHASE_SHORT_BREAK = 1;
|
||||
exports.PHASE_LONG_BREAK = 2;
|
||||
|
||||
exports.BUTTON_ICONS = {
|
||||
play: heatshrink.decompress(atob("jEYwMAkAGBnACBnwCBn+AAQPgAQPwAQP8AQP/AQXAAQPwAQP8AQP+AQgICBwQUCEAn4FggyBHAQ+CIgQ")),
|
||||
pause: heatshrink.decompress(atob("jEYwMA/4BBAX4CEA")),
|
||||
reset: heatshrink.decompress(atob("jEYwMA/4BB/+BAQPDAQPnAQIAKv///0///8j///EP//wAQQICBwQUCEhgyCHAQ+CIgI=")),
|
||||
skip: heatshrink.decompress(atob("jEYwMAwEIgHAhkA8EOgHwh8A/EPwH8h/A/0P8H/h/w/+P/H/5/8//v/3/AAoICBwQUCDQIgCEwQsCGQQ4CHwRECA"))
|
||||
};
|
||||
|
||||
exports.settings = storage.readJSON(exports.SETTINGS_PATH);
|
||||
if (!exports.settings) {
|
||||
exports.settings = {
|
||||
workTime: 1500000, //Work for 25 minutes
|
||||
shortBreak: 300000, //5 minute short break
|
||||
longBreak: 900000, //15 minute long break
|
||||
numShortBreaks: 3, //3 short breaks for every long break
|
||||
pausedTimerExpireTime: 21600000, //If the timer was left paused for >6 hours, reset it on next launch
|
||||
widget: false //If a widget is added in the future, whether the user wants it
|
||||
};
|
||||
}
|
||||
|
||||
//Store the minimal amount of information to be able to reconstruct the state of the timer at any given time.
|
||||
//This is necessary because it is necessary to write to flash to let the timer run in the background, so minimizing the writes is necessary.
|
||||
exports.STATE_DEFAULT = {
|
||||
wasRunning: false, //If the timer ever was running. Used to determine whether to display a reset button
|
||||
running: false, //Whether the timer is currently running
|
||||
startTime: 0, //When the timer was last started. Difference between this and now is how long timer has run continuously.
|
||||
pausedTime: 0, //When the timer was last paused. Used for expiration and displaying timer while paused.
|
||||
elapsedTime: 0, //How much time the timer had spent running before the current start time. Update on pause or user skipping stages.
|
||||
phase: exports.PHASE_WORKING, //What phase the timer is currently in
|
||||
numShortBreaks: 0 //Number of short breaks that have occured so far
|
||||
};
|
||||
exports.state = storage.readJSON(exports.STATE_PATH);
|
||||
if (!exports.state) {
|
||||
exports.state = exports.STATE_DEFAULT;
|
||||
}
|
||||
|
||||
//Get the number of milliseconds until the next phase change
|
||||
exports.getTimeLeft = function () {
|
||||
if (!exports.state.wasRunning) {
|
||||
//If the timer never ran, the time left is just the amount of work time.
|
||||
return exports.settings.workTime;
|
||||
} else if (exports.state.running) {
|
||||
//If the timer is running, the time left is current time - start time + preexisting time
|
||||
var runningTime = (new Date()).getTime() - exports.state.startTime + exports.state.elapsedTime;
|
||||
} else {
|
||||
//If the timer is not running, the same as above but use when the timer was paused instead of now.
|
||||
var runningTime = exports.state.pausedTime - exports.state.startTime + exports.state.elapsedTime;
|
||||
}
|
||||
|
||||
if (exports.state.phase == exports.PHASE_WORKING) {
|
||||
return exports.settings.workTime - runningTime;
|
||||
} else if (exports.state.phase == exports.PHASE_SHORT_BREAK) {
|
||||
return exports.settings.shortBreak - runningTime;
|
||||
} else {
|
||||
return exports.settings.longBreak - runningTime;
|
||||
}
|
||||
}
|
||||
|
||||
//Get the next phase to change to
|
||||
exports.getNextPhase = function () {
|
||||
if (exports.state.phase == exports.PHASE_WORKING) {
|
||||
if (exports.state.numShortBreaks < exports.settings.numShortBreaks) {
|
||||
return exports.PHASE_SHORT_BREAK;
|
||||
} else {
|
||||
return exports.PHASE_LONG_BREAK;
|
||||
}
|
||||
} else {
|
||||
return exports.PHASE_WORKING;
|
||||
}
|
||||
}
|
||||
|
||||
//Change to the next phase and update numShortBreaks, and optionally vibrate. DOES NOT WRITE STATE CHANGE TO STORAGE!
|
||||
exports.nextPhase = function (vibrate) {
|
||||
a = {
|
||||
startTime: 0, //When the timer was last started. Difference between this and now is how long timer has run continuously.
|
||||
pausedTime: 0, //When the timer was last paused. Used for expiration and displaying timer while paused.
|
||||
elapsedTime: 0, //How much time the timer had spent running before the current start time. Update on pause or user skipping stages.
|
||||
phase: exports.PHASE_WORKING, //What phase the timer is currently in
|
||||
numShortBreaks: 0 //Number of short breaks that have occured so far
|
||||
}
|
||||
let now = (new Date()).getTime();
|
||||
exports.state.startTime = now; //The timer is being reset, so say it starts now.
|
||||
exports.state.pausedTime = now; //This prevents a paused timer from having the start time moved to the future and therefore having been run for negative time.
|
||||
exports.state.elapsedTime = 0; //Because we are resetting the timer, we no longer need to care about whether it was paused previously.
|
||||
|
||||
let oldPhase = exports.state.phase; //Cache the old phase because we need to remember it when counting the number of short breaks
|
||||
exports.state.phase = exports.getNextPhase();
|
||||
|
||||
if (oldPhase == exports.PHASE_SHORT_BREAK) {
|
||||
//If we just left a short break, increase the number of short breaks
|
||||
exports.state.numShortBreaks++;
|
||||
} else if (oldPhase == exports.PHASE_LONG_BREAK) {
|
||||
//If we just left a long break, set the number of short breaks to zero
|
||||
exports.state.numShortBreaks = 0;
|
||||
}
|
||||
|
||||
if (vibrate) {
|
||||
if (exports.state.phase == exports.PHASE_WORKING) {
|
||||
Bangle.buzz(750, 1);
|
||||
} else if (exports.state.phase == exports.PHASE_SHORT_BREAK) {
|
||||
Bangle.buzz();
|
||||
setTimeout(Bangle.buzz, 400);
|
||||
} else {
|
||||
Bangle.buzz();
|
||||
setTimeout(Bangle.buzz, 400);
|
||||
setTimeout(Bangle.buzz, 600);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
require("heatshrink").decompress(atob("mEwwcBkmSpICG5AIHCLMmoQOKycJAoUyiQRLAgIOBAQQyKsmSpAROpgEBhmQzIHBC4JTIDIxcHDQYgCBQUSphEGpMJkwcEwgLByBoFCIMyCIgpDL4RQEBwWQ5ICBDoRKCBAIFBNYeSjJHDKYYaCR4YLBiYgDKYo4DEwQgECIpiECISqFkJlCCIILETwYRGDo1CsiJECIiPCdIaqCSoabFCgYRHAQ5iBCJ8hcAgRNKwOQgARLU4IRCvwRMa4QRPfwQR5YooR/cAYOGgAADvwEDCI8H/4AG/Ek5IRXGpMkzJZNoQGByYRNiQJCsgRLyAJDpgRQpIRLwgJEWxARBkIJFUg4RChIJGQA4RJNw4RKLg0kCJQ4DVoUACIY"))
|
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 2.8 KiB |
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"id": "pomoplus",
|
||||
"name": "Pomodoro Plus",
|
||||
"version": "0.05",
|
||||
"description": "A configurable pomodoro timer that runs in the background.",
|
||||
"icon": "icon.png",
|
||||
"type": "app",
|
||||
"tags": "pomodoro,cooking,tools",
|
||||
"supports": [
|
||||
"BANGLEJS",
|
||||
"BANGLEJS2"
|
||||
],
|
||||
"allow_emulator": true,
|
||||
"storage": [
|
||||
{
|
||||
"name": "pomoplus.app.js",
|
||||
"url": "app.js"
|
||||
},
|
||||
{
|
||||
"name": "pomoplus.img",
|
||||
"url": "icon.js",
|
||||
"evaluate": true
|
||||
},
|
||||
{
|
||||
"name": "pomoplus.boot.js",
|
||||
"url": "boot.js"
|
||||
},
|
||||
{
|
||||
"name": "pomoplus-com.js",
|
||||
"url": "common.js"
|
||||
},
|
||||
{
|
||||
"name": "pomoplus.settings.js",
|
||||
"url": "settings.js"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
const SETTINGS_PATH = 'pomoplus.json';
|
||||
const storage = require("Storage");
|
||||
|
||||
(function (back) {
|
||||
let settings = storage.readJSON(SETTINGS_PATH);
|
||||
if (!settings) {
|
||||
settings = {
|
||||
workTime: 1500000, //Work for 25 minutes
|
||||
shortBreak: 300000, //5 minute short break
|
||||
longBreak: 900000, //15 minute long break
|
||||
numShortBreaks: 3, //3 short breaks for every long break
|
||||
pausedTimerExpireTime: 21600000, //If the timer was left paused for >6 hours, reset it on next launch
|
||||
widget: false //If a widget is added in the future, whether the user wants it
|
||||
};
|
||||
}
|
||||
|
||||
function save() {
|
||||
storage.writeJSON(SETTINGS_PATH, settings);
|
||||
}
|
||||
|
||||
const menu = {
|
||||
'': { 'title': 'Pomodoro Plus' },
|
||||
'< Back': back,
|
||||
'Work time': {
|
||||
value: settings.workTime,
|
||||
step: 60000, //1 minute
|
||||
min: 60000,
|
||||
// max: 10800000,
|
||||
// wrap: true,
|
||||
onchange: function (value) {
|
||||
settings.workTime = value;
|
||||
save();
|
||||
},
|
||||
format: function (value) {
|
||||
return '' + (value / 60000) + 'm'
|
||||
}
|
||||
},
|
||||
'Short break time': {
|
||||
value: settings.shortBreak,
|
||||
step: 60000,
|
||||
min: 60000,
|
||||
// max: 10800000,
|
||||
// wrap: true,
|
||||
onchange: function (value) {
|
||||
settings.shortBreak = value;
|
||||
save();
|
||||
},
|
||||
format: function (value) {
|
||||
return '' + (value / 60000) + 'm'
|
||||
}
|
||||
},
|
||||
'# Short breaks': {
|
||||
value: settings.numShortBreaks,
|
||||
step: 1,
|
||||
min: 0,
|
||||
// max: 10800000,
|
||||
// wrap: true,
|
||||
onchange: function (value) {
|
||||
settings.numShortBreaks = value;
|
||||
save();
|
||||
}
|
||||
},
|
||||
'Long break time': {
|
||||
value: settings.longBreak,
|
||||
step: 60000,
|
||||
min: 60000,
|
||||
// max: 10800000,
|
||||
// wrap: true,
|
||||
onchange: function (value) {
|
||||
settings.longBreak = value;
|
||||
save();
|
||||
},
|
||||
format: function (value) {
|
||||
return '' + (value / 60000) + 'm'
|
||||
}
|
||||
},
|
||||
'Timer expiration': {
|
||||
value: settings.pausedTimerExpireTime,
|
||||
step: 900000, //15 minutes
|
||||
min: 0,
|
||||
// max: 10800000,
|
||||
// wrap: true,
|
||||
onchange: function (value) {
|
||||
settings.pausedTimerExpireTime = value;
|
||||
save();
|
||||
},
|
||||
format: function (value) {
|
||||
if (value == 0) return "Off"
|
||||
else return `${Math.floor(value / 3600000)}h ${(value % 3600000) / 60000}m`
|
||||
}
|
||||
},
|
||||
};
|
||||
E.showMenu(menu)
|
||||
})
|
|
@ -0,0 +1,2 @@
|
|||
0.01: New app!
|
||||
0.02: Submitted to the app loader
|
|
@ -0,0 +1,205 @@
|
|||
let n = 1;
|
||||
let diceSides = 6;
|
||||
let replacement = false;
|
||||
let min = 1;
|
||||
let max = 10;
|
||||
|
||||
Bangle.loadWidgets();
|
||||
Bangle.drawWidgets();
|
||||
|
||||
function showCoinMenu() {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Coin flip',
|
||||
'back': showMainMenu
|
||||
},
|
||||
'# of coins': {
|
||||
value: n,
|
||||
step: 1,
|
||||
min: 1,
|
||||
onchange: value => n = value
|
||||
},
|
||||
'Go': () => {
|
||||
let resultMenu = {
|
||||
'': {
|
||||
'title': 'Result',
|
||||
'back': showCoinMenu
|
||||
}
|
||||
};
|
||||
let heads = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let coin = Math.random() < 0.5;
|
||||
if (coin) heads++;
|
||||
resultMenu[`${i + 1}: ${coin ? 'Heads' : 'Tails'}`] = () => { };
|
||||
}
|
||||
let tails = n - heads;
|
||||
resultMenu[`${heads} heads, ${Math.round(100 * heads / n)}%`] = () => { };
|
||||
resultMenu[`${tails} tails, ${Math.round(100 * tails / n)}%`] = () => { };
|
||||
|
||||
E.showMenu(resultMenu);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function showDiceMenu() {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Dice roll',
|
||||
'back': showMainMenu
|
||||
},
|
||||
'# of dice': {
|
||||
value: n,
|
||||
step: 1,
|
||||
min: 1,
|
||||
onchange: value => n = value
|
||||
},
|
||||
'# of sides': {
|
||||
value: diceSides,
|
||||
step: 1,
|
||||
min: 2,
|
||||
onchange: value => diceSides = value
|
||||
},
|
||||
'Go': () => {
|
||||
let resultMenu = {
|
||||
'': {
|
||||
'title': 'Result',
|
||||
'back': showDiceMenu
|
||||
}
|
||||
};
|
||||
let sum = 0;
|
||||
let min = diceSides + 1;
|
||||
let max = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let roll = Math.floor(Math.random() * diceSides + 1);
|
||||
sum += roll;
|
||||
if (roll < min) min = roll;
|
||||
if (roll > max) max = roll;
|
||||
resultMenu[`${i + 1}: ${roll}`] = () => { };
|
||||
}
|
||||
resultMenu[`Sum: ${sum}`] = () => { };
|
||||
resultMenu[`Min: ${min}`] = () => { };
|
||||
resultMenu[`Max: ${max}`] = () => { };
|
||||
resultMenu[`Average: ${sum / n}`] = () => { };
|
||||
|
||||
E.showMenu(resultMenu);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function showCardMenu() {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Card draw',
|
||||
'back': showMainMenu
|
||||
},
|
||||
'# of cards': {
|
||||
value: Math.min(52, n),
|
||||
step: 1,
|
||||
min: 1,
|
||||
max: 52,
|
||||
onchange: value => n = value
|
||||
},
|
||||
'Replacement': {
|
||||
value: replacement,
|
||||
onchange: value => {
|
||||
replacement = value;
|
||||
if (replacement && n > 52) n = 52;
|
||||
}
|
||||
},
|
||||
'Go': () => {
|
||||
n = Math.min(n, 52);
|
||||
SUITS = ['Spades', 'Diamonds', 'Clubs', 'Hearts'];
|
||||
RANKS = ['Ace', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'Jack', 'Queen', 'King'];
|
||||
class Card {
|
||||
constructor(suit, rank) {
|
||||
this.suit = suit;
|
||||
this.rank = rank;
|
||||
}
|
||||
|
||||
//Can't use == to check equality, so using Java-inspired .equals()
|
||||
equals(other) {
|
||||
return this.suit == other.suit && this.rank == other.rank;
|
||||
}
|
||||
}
|
||||
|
||||
let resultMenu = {
|
||||
'': {
|
||||
'title': 'Result',
|
||||
'back': showCardMenu
|
||||
}
|
||||
};
|
||||
let cards = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
let newCard;
|
||||
while (true) {
|
||||
newCard = new Card(
|
||||
SUITS[Math.floor(Math.random() * SUITS.length)],
|
||||
RANKS[Math.floor(Math.random() * RANKS.length)]);
|
||||
|
||||
if (replacement) break; //If we are doing replacement, skip the check for duplicates and stop looping
|
||||
|
||||
if (!cards.map(card => card.equals(newCard)).includes(true)) break; //If there are no duplicates found, stop looping
|
||||
}
|
||||
|
||||
cards.push(newCard);
|
||||
resultMenu[`${newCard.rank} of ${newCard.suit}`] = () => { };
|
||||
}
|
||||
|
||||
E.showMenu(resultMenu);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showNumberMenu() {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Number choice',
|
||||
'back': showMainMenu
|
||||
},
|
||||
'Minimum': {
|
||||
value: min,
|
||||
step: 1,
|
||||
onchange: value => min = value
|
||||
},
|
||||
'Maximum': {
|
||||
value: max,
|
||||
step: 1,
|
||||
onchange: value => max = value
|
||||
},
|
||||
'# of choices': {
|
||||
value: n,
|
||||
min: 1,
|
||||
step: 1,
|
||||
onchange: value => n = value
|
||||
},
|
||||
'Go': () => {
|
||||
let resultMenu = {
|
||||
'': {
|
||||
'title': 'Result',
|
||||
'back': showNumberMenu
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < n; i++) {
|
||||
let value = Math.floor(min + Math.random() * (max - min + 1));
|
||||
resultMenu[`${i + 1}: ${value}`] = () => { };
|
||||
}
|
||||
E.showMenu(resultMenu);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showMainMenu() {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Random'
|
||||
},
|
||||
'Coin': showCoinMenu,
|
||||
'Dice': showDiceMenu,
|
||||
'Card': showCardMenu,
|
||||
'Number': showNumberMenu
|
||||
});
|
||||
}
|
||||
|
||||
showMainMenu();
|
|
@ -0,0 +1 @@
|
|||
require("heatshrink").decompress(atob("mEwwIEBgf///AAoMHAoPgAoMPAoPwAoMfAoP4AoM/AoP8AoN/AoP+AoIEBAAMAgIbBD4OAAoPgFYIFC4A3BAoQCFEAQFBEwV/AoIyCn+ALYYFFCIIFDDoIECFIQFCGoQFCIIQFCJoQFCNoIuEHwQuCHwQuCQYQuCR4QuCTYQuGAoIcDg4oEg4oEg6mCAoQuDAoIuDAFQvFAsIA=="))
|
After Width: | Height: | Size: 378 B |
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"id": "random",
|
||||
"name": "Random",
|
||||
"version": "0.02",
|
||||
"description": "Flip coins, roll dice, draw a card, or choose random numbers",
|
||||
"icon": "icon.png",
|
||||
"tags": "tool",
|
||||
"supports": [
|
||||
"BANGLEJS",
|
||||
"BANGLEJS2"
|
||||
],
|
||||
"allow_emulator": true,
|
||||
"storage": [
|
||||
{
|
||||
"name": "random.app.js",
|
||||
"url": "app.js"
|
||||
},
|
||||
{
|
||||
"name": "random.img",
|
||||
"url": "icon.js",
|
||||
"evaluate": true
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
0.01: New app!
|
||||
0.02: Bug fixes
|
||||
0.03: Submitted to the app loader
|
|
@ -0,0 +1,38 @@
|
|||
# Scientific calculator
|
||||
|
||||
This is a reverse polish notation scientific calculator with memory function.
|
||||
|
||||
## General operation
|
||||
|
||||
In order to fit all of the functions, the calculator only displays 12 keys at a time. The top right of these keys is always a mode button. This will take you into number mode if you are in operation mode, and operation mode if you are in any other mode. The calculator starts out in number mode, where you can enter numbers. Operation mode lets you perform basic operations and RPN stack manipulation. In operation mode, you can press "Sci" to switch to Scientific mode. This provides trigonometric, logarithmic, and memory operations, as well as the constants e and pi.
|
||||
|
||||
In any mode, the calculator also accepts swipes. A swipe up or down will function as the enter key (see below), and a swipe left will delete the last character if entry has not been terminated (see below) or function as the clear key if it has.
|
||||
|
||||
The calculator will vibrate when performing an operation. If the operation is invalid, it will do a long vibration.
|
||||
|
||||
## Reverse polish notation
|
||||
|
||||
To save keystrokes and avoid the need for parentheses keys while still allowing you to control the order of operations, this calculator uses Reverse Polish Notation (RPN). There is a stack of 4 registers: x (the displayed value), y, z, and t (top). Pressing Enter will lift the stack. The value of z will be copied to t, y to z, and x to y. (The old value of t is lost.) This also terminates input, making the next numerical key press clear the value in x before typing its value. This enables you to enter a value into the stack multiple times by pressing Enter multiple times.
|
||||
|
||||
Performing an operation will also terminate entry, and can either simply replace the value of x (if it is a one-number operation), or drop the stack (if it is a two number operation). Dropping the stack causes the existing values of x and y to be lost, replacing x with the result of the operation, y with the old value of z, and z with the old value of t. t remains the same.
|
||||
|
||||
Effectively, to do an operation, you type the first operand, press Enter, and then type the second operand. If you want to do multiple operations, start with the one that you want to do first, and then continue operating on the result without pressing enter. For example, 3 Enter 2 Times 1 Plus computes (3\*2) + 1. 3 Enter 1 Plus 2 Times computes (3+1) \* 2. If you wish to compute something independently, simply press enter before starting the independent operation. For example, to compute (3 \* 2) + (4 \* 5), first compute 3 \* 2. Then press enter and compute 4 \* 5. You will have 6 in the y register and 20 in the x register. Press Plus to add them.
|
||||
|
||||
You can also rotate the stack down with the Rot key. x gets set to the value of y, y gets set to the value of z, z gets set to the value of t, and t gets set to the old value of x. And you can swap x and y with Swp. I find this to be most handy when I want to subtract the result of an operation from another value, but I forget to enter another value first. For example, 20 - (2 \* 3) should usually be computed as 20 Enter 2 Enter 3 Times Minus. But if you compute 2 \* 3 first, you can enter 20, swap the values, and then subtract. (I do this more often than I would like to admit.)
|
||||
|
||||
## Memory
|
||||
The calculator has 10 variables that you can store values in. In Scientific mode, press Sto to store the value of the x register in one of the values (which you then choose by pressing a number), or Rcl to read a value (which you choose by pressing a number) into the x register. These values are preserved when the calculator is closed.
|
||||
|
||||
## Clearing
|
||||
|
||||
A swipe left will delete one character, unless the number is already zero in which case it will emulate a press of the clear button (Clr). The clear button will set the value of x to 0 if it is not zero. If x=0, y, z, and t will be cleared to zero. And if they are already zero, pressing Clear again will clear the memory.
|
||||
|
||||
## Limitations
|
||||
|
||||
* This calculator uses Javascript's floating point numbers. These are fast, space efficient, and less complicated to code for (producing a smaller app), but they sacrifice some precision. You might see stuff like 0.1 + 0.2 adding to 0.30000000000000004, or the sine of pi being a very low value but not quite zero.
|
||||
|
||||
* This calculator performs trigonometric operations in radians. If you wish to convert degrees to radians, multiply by (pi/180). If you wish to convert radians to degrees, multiply by (180 / pi).
|
||||
|
||||
* This calculator performs logarithms in base 10. If you would like to perform logarithms in another base, divide the log of the number by the log of the base. For example, to compute log base 2 of 8, divide log(8) by log(2). (To get the natural log or ln, divide by log(e)).
|
||||
|
||||
* This calculator considers 0^0 to be 1, a behavior inherited from Javascripts Math.pow() function. In reality, it is undefined because two mathematical rules give conflicting answers: anything^0 = 1, but 0^anything = 0.
|
|
@ -0,0 +1,403 @@
|
|||
const MEMORY_FILE = "rpnsci.mem.json";
|
||||
const storage = require("Storage");
|
||||
|
||||
class NumberButton {
|
||||
constructor(number) {
|
||||
this.label = '' + number;
|
||||
}
|
||||
|
||||
onclick() {
|
||||
if (entryTerminated) {
|
||||
if (liftOnNumberPress) liftStack();
|
||||
x = this.label;
|
||||
entryTerminated = false;
|
||||
liftOnNumberPress = false;
|
||||
} else {
|
||||
if (x == '0') x = this.label;
|
||||
else x += this.label;
|
||||
}
|
||||
feedback(true);
|
||||
updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
let DecimalPointButton = {
|
||||
label: '.',
|
||||
onclick: () => {
|
||||
if (entryTerminated) {
|
||||
if (liftOnNumberPress) liftStack();
|
||||
x = '0.';
|
||||
entryTerminated = false;
|
||||
liftOnNumberPress = false;
|
||||
feedback(true);
|
||||
updateDisplay();
|
||||
} else if (!x.includes('.')) {
|
||||
x += '.';
|
||||
feedback(true);
|
||||
updateDisplay();
|
||||
} else {
|
||||
feedback(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class ModeButton {
|
||||
constructor(currentMode) {
|
||||
if (currentMode == 'memstore' || currentMode == 'memrec') {
|
||||
this.label = 'Exit';
|
||||
} else if (currentMode == 'operation') {
|
||||
this.label = 'Num';
|
||||
} else {
|
||||
this.label = 'Op';
|
||||
}
|
||||
}
|
||||
|
||||
onclick() {
|
||||
if (mode == 'memstore' || mode == 'memrec') {
|
||||
mode = 'operation';
|
||||
} else if (mode == 'operation') {
|
||||
mode = 'number';
|
||||
} else {
|
||||
mode = 'operation';
|
||||
}
|
||||
feedback(true);
|
||||
drawButtons();
|
||||
}
|
||||
}
|
||||
|
||||
class OperationButton {
|
||||
constructor(label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
onclick() {
|
||||
if (this.label == '/' && parseFloat(x) == 0) {
|
||||
feedback(false);
|
||||
return;
|
||||
}
|
||||
let result = this.getResult();
|
||||
x = '' + result;
|
||||
y = z;
|
||||
z = t;
|
||||
entryTerminated = true;
|
||||
liftOnNumberPress = true;
|
||||
feedback(true);
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
getResult() {
|
||||
let numX = parseFloat(x);
|
||||
return {
|
||||
'+': y + numX,
|
||||
'-': y - numX,
|
||||
'/': y / numX,
|
||||
'*': y * numX,
|
||||
'^': Math.pow(y, numX)
|
||||
}[this.label];
|
||||
}
|
||||
}
|
||||
|
||||
class OneNumOpButton {
|
||||
constructor(label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
onclick() {
|
||||
result = {
|
||||
'+-': '' + -parseFloat(x),
|
||||
'Sin': '' + Math.sin(parseFloat(x)),
|
||||
'Cos': '' + Math.cos(parseFloat(x)),
|
||||
'Tan': '' + Math.tan(parseFloat(x)),
|
||||
'Asin': '' + Math.asin(parseFloat(x)),
|
||||
'Acos': '' + Math.acos(parseFloat(x)),
|
||||
'Atan': '' + Math.atan(parseFloat(x)),
|
||||
'Log': '' + (Math.log(parseFloat(x)) / Math.log(10))
|
||||
}[this.label];
|
||||
if (isNaN(result) || result == 'NaN') feedback(false);
|
||||
else {
|
||||
x = result;
|
||||
entryTerminated = true;
|
||||
liftOnNumberPress = true;
|
||||
feedback(true);
|
||||
updateDisplay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ClearButton = {
|
||||
label: 'Clr',
|
||||
onclick: () => {
|
||||
if (x != '0') {
|
||||
x = '0';
|
||||
updateDisplay();
|
||||
} else if (y != 0 || z != 0 || t != 0) {
|
||||
y = 0;
|
||||
z = 0;
|
||||
t = 0;
|
||||
E.showMessage('Registers cleared!');
|
||||
setTimeout(() => {
|
||||
drawButtons();
|
||||
updateDisplay();
|
||||
}, 250);
|
||||
} else {
|
||||
memory = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
storage.writeJSON(MEMORY_FILE, memory);
|
||||
E.showMessage('Memory cleared!');
|
||||
setTimeout(() => {
|
||||
drawButtons();
|
||||
updateDisplay();
|
||||
}, 250);
|
||||
}
|
||||
entryTerminated = false;
|
||||
liftOnNumberPress = false;
|
||||
feedback(true);
|
||||
}
|
||||
};
|
||||
|
||||
let SwapButton = {
|
||||
label: 'Swp',
|
||||
onclick: () => {
|
||||
oldX = x;
|
||||
x = '' + y;
|
||||
y = parseFloat(oldX);
|
||||
entryTerminated = true;
|
||||
liftOnNumberPress = true;
|
||||
feedback(true);
|
||||
updateDisplay();
|
||||
}
|
||||
};
|
||||
|
||||
let RotateButton = {
|
||||
label: 'Rot',
|
||||
onclick: () => {
|
||||
oldX = x;
|
||||
x = '' + y;
|
||||
y = z;
|
||||
z = t;
|
||||
t = parseFloat(oldX);
|
||||
entryTerminated = true;
|
||||
liftOnNumberPress = true;
|
||||
feedback(true);
|
||||
updateDisplay();
|
||||
}
|
||||
};
|
||||
|
||||
let EnterButton = {
|
||||
label: 'Ent',
|
||||
onclick: () => {
|
||||
liftStack();
|
||||
entryTerminated = true;
|
||||
liftOnNumberPress = false;
|
||||
feedback(true);
|
||||
updateDisplay();
|
||||
}
|
||||
};
|
||||
|
||||
let ScientificButton = {
|
||||
label: 'Sci',
|
||||
onclick: () => {
|
||||
mode = 'scientific';
|
||||
feedback(true);
|
||||
drawButtons();
|
||||
}
|
||||
};
|
||||
|
||||
class ConstantButton {
|
||||
constructor(label, value) {
|
||||
this.label = label;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
onclick() {
|
||||
if (entryTerminated && liftOnNumberPress) liftStack();
|
||||
x = '' + this.value;
|
||||
entryTerminated = true;
|
||||
liftOnNumberPress = true;
|
||||
feedback(true);
|
||||
updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
let MemStoreButton = {
|
||||
label: 'Sto',
|
||||
onclick: () => {
|
||||
mode = 'memstore';
|
||||
feedback(true);
|
||||
drawButtons();
|
||||
}
|
||||
};
|
||||
|
||||
let MemRecallButton = {
|
||||
label: 'Rec',
|
||||
onclick: () => {
|
||||
mode = 'memrec';
|
||||
feedback(true);
|
||||
drawButtons();
|
||||
}
|
||||
};
|
||||
|
||||
class MemStoreIn {
|
||||
constructor(register) {
|
||||
this.register = register;
|
||||
this.label = '' + register;
|
||||
}
|
||||
|
||||
onclick() {
|
||||
memory[this.register] = parseFloat(x);
|
||||
storage.writeJSON(MEMORY_FILE, memory);
|
||||
mode = 'scientific';
|
||||
entryTerminated = true;
|
||||
liftOnNumberPress = true;
|
||||
feedback(true);
|
||||
drawButtons();
|
||||
}
|
||||
}
|
||||
|
||||
class MemRecFrom {
|
||||
constructor(register) {
|
||||
this.register = register;
|
||||
this.label = '' + register;
|
||||
}
|
||||
|
||||
onclick() {
|
||||
x = '' + memory[this.register];
|
||||
mode = 'scientific';
|
||||
entryTerminated = true;
|
||||
liftOnNumberPress = true;
|
||||
feedback(true);
|
||||
updateDisplay();
|
||||
drawButtons();
|
||||
}
|
||||
}
|
||||
|
||||
const BUTTONS = {
|
||||
'number': [
|
||||
[new NumberButton(7), new NumberButton(8), new NumberButton(9), new ModeButton('number')],
|
||||
[new NumberButton(4), new NumberButton(5), new NumberButton(6), new NumberButton(0)],
|
||||
[new NumberButton(1), new NumberButton(2), new NumberButton(3), DecimalPointButton]
|
||||
],
|
||||
'operation': [
|
||||
[new OperationButton('+'), new OperationButton('-'), ClearButton, new ModeButton('operation')],
|
||||
[new OperationButton('*'), new OperationButton('/'), SwapButton, EnterButton],
|
||||
[new OperationButton('^'), new OneNumOpButton('+-'), RotateButton, ScientificButton]
|
||||
],
|
||||
'scientific': [
|
||||
[new OneNumOpButton('Sin'), new OneNumOpButton('Cos'), new OneNumOpButton('Tan'), new ModeButton('scientific')],
|
||||
[new OneNumOpButton('Asin'), new OneNumOpButton('Acos'), new OneNumOpButton('Atan'), MemStoreButton],
|
||||
[new OneNumOpButton('Log'), new ConstantButton('e', Math.E), new ConstantButton('pi', Math.PI), MemRecallButton]
|
||||
],
|
||||
'memstore': [
|
||||
[new MemStoreIn(7), new MemStoreIn(8), new MemStoreIn(9), new ModeButton('memstore')],
|
||||
[new MemStoreIn(4), new MemStoreIn(5), new MemStoreIn(6), new MemStoreIn(0)],
|
||||
[new MemStoreIn(1), new MemStoreIn(2), new MemStoreIn(3), new ModeButton('memstore')]
|
||||
],
|
||||
'memrec': [
|
||||
[new MemRecFrom(7), new MemRecFrom(8), new MemRecFrom(9), new ModeButton('memrec')],
|
||||
[new MemRecFrom(4), new MemRecFrom(5), new MemRecFrom(6), new MemRecFrom(0)],
|
||||
[new MemRecFrom(1), new MemRecFrom(2), new MemRecFrom(3), new ModeButton('memrec')]
|
||||
],
|
||||
};
|
||||
|
||||
let x = '0';
|
||||
let y = 0;
|
||||
let z = 0;
|
||||
let t = 0;
|
||||
let memJSON = storage.readJSON(MEMORY_FILE);
|
||||
if (memJSON) {
|
||||
let memory = memJSON;
|
||||
} else {
|
||||
let memory = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
}
|
||||
let mode = 'number';
|
||||
let entryTerminated = false;
|
||||
let liftOnNumberPress = false;
|
||||
|
||||
function liftStack() {
|
||||
t = z;
|
||||
z = y;
|
||||
y = parseFloat(x);
|
||||
}
|
||||
|
||||
function feedback(acceptable) {
|
||||
if (acceptable) Bangle.buzz(50, 0.5);
|
||||
else Bangle.buzz(200, 1);
|
||||
}
|
||||
|
||||
function drawButtons() {
|
||||
g.reset().clearRect(0, 44, 175, 175).setFont("Vector", 15).setFontAlign(0, 0);
|
||||
//Draw lines
|
||||
for (let x = 44; x <= 176; x += 44) {
|
||||
g.drawLine(x, 44, x, 175);
|
||||
}
|
||||
for (let y = 44; y <= 176; y += 44) {
|
||||
g.drawLine(0, y, 175, y);
|
||||
}
|
||||
for (let row = 0; row < 3; row++) {
|
||||
for (let col = 0; col < 4; col++) {
|
||||
g.drawString(BUTTONS[mode][row][col].label, 22 + 44 * col, 66 + 44 * row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFontSize(length) {
|
||||
let size = Math.floor(176 / length); //Characters of width needed per pixel
|
||||
size *= (20 / 12); //Convert to height
|
||||
// Clamp to between 6 and 20
|
||||
if (size < 6) return 6;
|
||||
else if (size > 20) return 20;
|
||||
else return Math.floor(size);
|
||||
}
|
||||
|
||||
function updateDisplay() {
|
||||
g.clearRect(0, 24, 175, 43).setColor(storage.readJSON('setting.json').theme.fg2).setFontAlign(1, -1).setFont("Vector", getFontSize(x.length)).drawString(x, 176, 24);
|
||||
}
|
||||
|
||||
Bangle.on("touch", (button, xy) => {
|
||||
let row = Math.floor((xy.y - 44) / 44);
|
||||
let col = Math.floor(xy.x / 44);
|
||||
if (row < 0) { // Tap number to show registers
|
||||
g.clearRect(0, 24, 175, 43).setColor(storage.readJSON('setting.json').theme.fg2).setFontAlign(1, -1)
|
||||
.setFont("Vector", getFontSize(x.length)).drawString('' + t, 176, 24);
|
||||
|
||||
g.clearRect(0, 44, 175, 63).setColor(storage.readJSON('setting.json').theme.fg2).setFontAlign(1, -1)
|
||||
.setFont("Vector", getFontSize(x.length)).drawString('' + z, 176, 44);
|
||||
|
||||
g.clearRect(0, 64, 175, 83).setColor(storage.readJSON('setting.json').theme.fg2).setFontAlign(1, -1)
|
||||
.setFont("Vector", getFontSize(x.length)).drawString('' + y, 176, 64);
|
||||
|
||||
g.clearRect(0, 84, 175, 103).setColor(storage.readJSON('setting.json').theme.fg2).setFontAlign(1, -1)
|
||||
.setFont("Vector", getFontSize(x.length)).drawString(x, 176, 84);
|
||||
|
||||
setTimeout(() => {
|
||||
drawButtons();
|
||||
updateDisplay();
|
||||
}, 500);
|
||||
} else {
|
||||
if (row > 2) row = 2;
|
||||
if (col < 0) col = 0;
|
||||
if (col > 3) col = 3;
|
||||
|
||||
BUTTONS[mode][row][col].onclick();
|
||||
}
|
||||
});
|
||||
|
||||
Bangle.on("swipe", dir => {
|
||||
if (dir == -1) {
|
||||
if (entryTerminated) ClearButton.onclick();
|
||||
else if (x.length == 1) x = '0';
|
||||
else x = x.substring(0, x.length - 1);
|
||||
|
||||
feedback(true);
|
||||
updateDisplay();
|
||||
} else if (dir == 0) {
|
||||
EnterButton.onclick();
|
||||
}
|
||||
});
|
||||
|
||||
g.clear().reset();
|
||||
|
||||
drawButtons();
|
||||
updateDisplay();
|
||||
|
||||
Bangle.loadWidgets();
|
||||
Bangle.drawWidgets();
|
|
@ -0,0 +1 @@
|
|||
require("heatshrink").decompress(atob("mEwwcCkmSpICEpEEBAwCICP4CCk/yCP4RVyf/AAXkCK0///5Nf4RffcYR/AQkAAERr/CKn+CK9//+f/41O/mT5IRO/+eLJ8/CIw+BAAP8CIkn+QRQMQY1MCKM8z5rP8mf/KzO8mTCJ1/CIP/8j7pCP4RMA=="))
|
After Width: | Height: | Size: 765 B |
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"id": "rpnsci",
|
||||
"name": "RPN Scientific Calculator",
|
||||
"shortName": "Calculator",
|
||||
"icon": "icon.png",
|
||||
"version": "0.03",
|
||||
"description": "RPN scientific calculator with memory function.",
|
||||
"tags": "",
|
||||
"supports": [
|
||||
"BANGLEJS2"
|
||||
],
|
||||
"readme": "README.md",
|
||||
"storage": [
|
||||
{
|
||||
"name": "rpnsci.app.js",
|
||||
"url": "app.js"
|
||||
},
|
||||
{
|
||||
"name": "rpnsci.img",
|
||||
"url": "icon.js",
|
||||
"evaluate": "true"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
0.01: New app!
|
||||
0.02: Bug fixes
|
||||
0.03: Submitted to the app loader
|
|
@ -0,0 +1,304 @@
|
|||
const storage = require("Storage");
|
||||
const heatshrink = require("heatshrink");
|
||||
const STATE_PATH = "stlap.state.json";
|
||||
g.setFont("Vector", 24);
|
||||
const BUTTON_ICONS = {
|
||||
play: heatshrink.decompress(atob("jEYwMAkAGBnACBnwCBn+AAQPgAQPwAQP8AQP/AQXAAQPwAQP8AQP+AQgICBwQUCEAn4FggyBHAQ+CIgQ")),
|
||||
pause: heatshrink.decompress(atob("jEYwMA/4BBAX4CEA")),
|
||||
reset: heatshrink.decompress(atob("jEYwMA/4BB/+BAQPDAQPnAQIAKv///0///8j///EP//wAQQICBwQUCEhgyCHAQ+CIgI="))
|
||||
};
|
||||
|
||||
let state = storage.readJSON(STATE_PATH);
|
||||
const STATE_DEFAULT = {
|
||||
wasRunning: false, //If the stopwatch was ever running since being reset
|
||||
sessionStart: 0, //When the stopwatch was first started
|
||||
running: false, //Whether the stopwatch is currently running
|
||||
startTime: 0, //When the stopwatch was last started.
|
||||
pausedTime: 0, //When the stopwatch was last paused.
|
||||
elapsedTime: 0 //How much time was spent running before the current start time. Update on pause.
|
||||
};
|
||||
if (!state) {
|
||||
state = STATE_DEFAULT;
|
||||
}
|
||||
|
||||
let lapFile;
|
||||
let lapHistory;
|
||||
if (state.wasRunning) {
|
||||
lapFile = 'stlap-' + state.sessionStart + '.json';
|
||||
lapHistory = storage.readJSON(lapFile);
|
||||
if (!lapHistory)
|
||||
lapHistory = {
|
||||
final: false, //Whether the stopwatch has been reset. It is expected that the stopwatch app will create a final split when reset. If this is false, it is expected that this hasn't been done, and that the current time should be used as the "final split"
|
||||
splits: [] //List of times when the Lap button was pressed
|
||||
};
|
||||
} else
|
||||
lapHistory = {
|
||||
final: false, //Whether the stopwatch has been reset. It is expected that the stopwatch app will create a final split when reset. If this is false, it is expected that this hasn't been done, and that the current time should be used as the "final split"
|
||||
splits: [] //List of times when the Lap button was pressed
|
||||
};
|
||||
|
||||
//Get the number of milliseconds that stopwatch has run for
|
||||
function getTime() {
|
||||
if (!state.wasRunning) {
|
||||
//If the timer never ran, zero ms have passed
|
||||
return 0;
|
||||
} else if (state.running) {
|
||||
//If the timer is running, the time left is current time - start time + preexisting time
|
||||
return (new Date()).getTime() - state.startTime + state.elapsedTime;
|
||||
} else {
|
||||
//If the timer is not running, the same as above but use when the timer was paused instead of now.
|
||||
return state.pausedTime - state.startTime + state.elapsedTime;
|
||||
}
|
||||
}
|
||||
|
||||
let gestureMode = false;
|
||||
|
||||
function drawButtons() {
|
||||
//Draw the backdrop
|
||||
const BAR_TOP = g.getHeight() - 48;
|
||||
const BUTTON_Y = BAR_TOP + 12;
|
||||
const BUTTON_LEFT = g.getWidth() / 4 - 12; //For the buttons, we have to subtract 12 because images do not obey alignment, but their size is known in advance
|
||||
const TEXT_LEFT = g.getWidth() / 4; //For text, we do not have to subtract 12 because they do obey alignment.
|
||||
const BUTTON_MID = g.getWidth() / 2 - 12;
|
||||
const TEXT_MID = g.getWidth() / 2;
|
||||
const BUTTON_RIGHT = g.getHeight() * 3 / 4 - 12;
|
||||
|
||||
g.setColor(0, 0, 1).setFontAlign(0, -1)
|
||||
.clearRect(0, BAR_TOP, g.getWidth(), g.getHeight())
|
||||
.fillRect(0, BAR_TOP, g.getWidth(), g.getHeight())
|
||||
.setColor(1, 1, 1);
|
||||
|
||||
if (gestureMode)
|
||||
g.setFont('Vector', 16)
|
||||
.drawString('Button: Lap/Reset\nSwipe: Start/stop\nTap: Light', TEXT_MID, BAR_TOP);
|
||||
else {
|
||||
g.setFont('Vector', 24);
|
||||
if (!state.wasRunning) { //If the timer was never running:
|
||||
if (storage.read('stlapview.app.js') !== undefined) //If stlapview is installed, there should be a button to open it and a button to start the timer
|
||||
g.drawLine(g.getWidth() / 2, BAR_TOP, g.getWidth() / 2, g.getHeight())
|
||||
.drawString("Laps", TEXT_LEFT, BUTTON_Y)
|
||||
.drawImage(BUTTON_ICONS.play, BUTTON_RIGHT, BUTTON_Y);
|
||||
else g.drawImage(BUTTON_ICONS.play, BUTTON_MID, BUTTON_Y); //Otherwise, only a button to start the timer
|
||||
} else { //If the timer was running:
|
||||
g.drawLine(g.getWidth() / 2, BAR_TOP, g.getWidth() / 2, g.getHeight());
|
||||
if (state.running) { //If it is running now, have a lap button and a pause button
|
||||
g.drawString("LAP", TEXT_LEFT, BUTTON_Y)
|
||||
.drawImage(BUTTON_ICONS.pause, BUTTON_RIGHT, BUTTON_Y);
|
||||
} else { //If it is not running now, have a reset button and a
|
||||
g.drawImage(BUTTON_ICONS.reset, BUTTON_LEFT, BUTTON_Y)
|
||||
.drawImage(BUTTON_ICONS.play, BUTTON_RIGHT, BUTTON_Y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawTime() {
|
||||
function pad(number) {
|
||||
return ('00' + parseInt(number)).slice(-2);
|
||||
}
|
||||
|
||||
let time = getTime();
|
||||
g.reset(0, 0, 0)
|
||||
.setFontAlign(0, 0)
|
||||
.setFont("Vector", 36)
|
||||
.clearRect(0, 24, g.getWidth(), g.getHeight() - 48)
|
||||
|
||||
//Draw the time
|
||||
.drawString((() => {
|
||||
let hours = Math.floor(time / 3600000);
|
||||
let minutes = Math.floor((time % 3600000) / 60000);
|
||||
let seconds = Math.floor((time % 60000) / 1000);
|
||||
let hundredths = Math.floor((time % 1000) / 10);
|
||||
|
||||
if (hours >= 1) return `${hours}:${pad(minutes)}:${pad(seconds)}`;
|
||||
else return `${minutes}:${pad(seconds)}:${pad(hundredths)}`;
|
||||
})(), g.getWidth() / 2, g.getHeight() / 2);
|
||||
|
||||
//Draw the lap labels if necessary
|
||||
if (lapHistory.splits.length >= 1) {
|
||||
let lastLap = lapHistory.splits.length;
|
||||
let curLap = lastLap + 1;
|
||||
|
||||
g.setFont("Vector", 12)
|
||||
.drawString((() => {
|
||||
let lapTime = time - lapHistory.splits[lastLap - 1];
|
||||
let hours = Math.floor(lapTime / 3600000);
|
||||
let minutes = Math.floor((lapTime % 3600000) / 60000);
|
||||
let seconds = Math.floor((lapTime % 60000) / 1000);
|
||||
let hundredths = Math.floor((lapTime % 1000) / 10);
|
||||
|
||||
if (hours == 0) return `Lap ${curLap}: ${pad(minutes)}:${pad(seconds)}:${pad(hundredths)}`;
|
||||
else return `Lap ${curLap}: ${hours}:${pad(minutes)}:${pad(seconds)}:${pad(hundredths)}`;
|
||||
})(), g.getWidth() / 2, g.getHeight() / 2 + 18)
|
||||
.drawString((() => {
|
||||
let lapTime;
|
||||
if (lastLap == 1) lapTime = lapHistory.splits[lastLap - 1];
|
||||
else lapTime = lapHistory.splits[lastLap - 1] - lapHistory.splits[lastLap - 2];
|
||||
let hours = Math.floor(lapTime / 3600000);
|
||||
let minutes = Math.floor((lapTime % 3600000) / 60000);
|
||||
let seconds = Math.floor((lapTime % 60000) / 1000);
|
||||
let hundredths = Math.floor((lapTime % 1000) / 10);
|
||||
|
||||
if (hours == 0) return `Lap ${lastLap}: ${pad(minutes)}:${pad(seconds)}:${pad(hundredths)}`;
|
||||
else return `Lap ${lastLap}: ${hours}:${pad(minutes)}:${pad(seconds)}:${pad(hundredths)}`;
|
||||
})(), g.getWidth() / 2, g.getHeight() / 2 + 30);
|
||||
}
|
||||
}
|
||||
|
||||
drawButtons();
|
||||
|
||||
function firstTimeStart(now, time) {
|
||||
state = {
|
||||
wasRunning: true,
|
||||
sessionStart: Math.floor(now),
|
||||
running: true,
|
||||
startTime: now,
|
||||
pausedTime: 0,
|
||||
elapsedTime: 0,
|
||||
};
|
||||
lapFile = 'stlap-' + state.sessionStart + '.json';
|
||||
setupTimerInterval();
|
||||
Bangle.buzz(200);
|
||||
drawButtons();
|
||||
}
|
||||
|
||||
function split(now, time) {
|
||||
lapHistory.splits.push(time);
|
||||
Bangle.buzz();
|
||||
}
|
||||
|
||||
function pause(now, time) {
|
||||
//Record the exact moment that we paused
|
||||
state.pausedTime = now;
|
||||
|
||||
//Stop the timer
|
||||
state.running = false;
|
||||
stopTimerInterval();
|
||||
Bangle.buzz(200);
|
||||
drawTime();
|
||||
drawButtons();
|
||||
}
|
||||
|
||||
function reset(now, time) {
|
||||
//Record the time
|
||||
lapHistory.splits.push(time);
|
||||
lapHistory.final = true;
|
||||
storage.writeJSON(lapFile, lapHistory);
|
||||
|
||||
//Reset the timer
|
||||
state = STATE_DEFAULT;
|
||||
lapHistory = {
|
||||
final: false,
|
||||
splits: []
|
||||
};
|
||||
Bangle.buzz(500);
|
||||
drawTime();
|
||||
drawButtons();
|
||||
}
|
||||
|
||||
function start(now, time) {
|
||||
//Start the timer and record when we started
|
||||
state.elapsedTime += (state.pausedTime - state.startTime);
|
||||
state.startTime = now;
|
||||
state.running = true;
|
||||
setupTimerInterval();
|
||||
Bangle.buzz(200);
|
||||
drawTime();
|
||||
drawButtons();
|
||||
}
|
||||
|
||||
Bangle.on("touch", (button, xy) => {
|
||||
//In gesture mode, just turn on the light and then return
|
||||
if (gestureMode) {
|
||||
Bangle.setLCDPower(true);
|
||||
return;
|
||||
}
|
||||
|
||||
//If we support full touch and we're not touching the keys, ignore.
|
||||
//If we don't support full touch, we can't tell so just assume we are.
|
||||
if (xy !== undefined && xy.y <= g.getHeight() - 48) return;
|
||||
|
||||
let now = (new Date()).getTime();
|
||||
let time = getTime();
|
||||
|
||||
if (!state.wasRunning) {
|
||||
if (storage.read('stlapview.app.js') !== undefined) {
|
||||
//If we were never running and stlapview is installed, there are two buttons: open stlapview and start the timer
|
||||
if (button == 1) load('stlapview.app.js');
|
||||
else firstTimeStart(now, time);
|
||||
}
|
||||
//If stlapview there is only one button: the start button
|
||||
else firstTimeStart(now, time);
|
||||
} else if (state.running) {
|
||||
//If we are running, there are two buttons: lap and pause
|
||||
if (button == 1) split(now, time);
|
||||
else pause(now, time);
|
||||
|
||||
} else {
|
||||
//If we are stopped, there are two buttons: reset and continue
|
||||
if (button == 1) reset(now, time);
|
||||
else start(now, time);
|
||||
}
|
||||
});
|
||||
|
||||
Bangle.on('swipe', direction => {
|
||||
let now = (new Date()).getTime();
|
||||
let time = getTime();
|
||||
|
||||
if (gestureMode) {
|
||||
Bangle.setLCDPower(true);
|
||||
if (!state.wasRunning) firstTimeStart(now, time);
|
||||
else if (state.running) pause(now, time);
|
||||
else start(now, time);
|
||||
} else {
|
||||
gestureMode = true;
|
||||
Bangle.setOptions({
|
||||
lockTimeout: 0
|
||||
});
|
||||
drawTime();
|
||||
drawButtons();
|
||||
}
|
||||
});
|
||||
|
||||
setWatch(() => {
|
||||
let now = (new Date()).getTime();
|
||||
let time = getTime();
|
||||
|
||||
if (gestureMode) {
|
||||
Bangle.setLCDPower(true);
|
||||
if (state.running) split(now, time);
|
||||
else reset(now, time);
|
||||
}
|
||||
}, BTN1, { repeat: true });
|
||||
|
||||
let timerInterval;
|
||||
|
||||
function setupTimerInterval() {
|
||||
if (timerInterval !== undefined) {
|
||||
clearInterval(timerInterval);
|
||||
}
|
||||
timerInterval = setInterval(drawTime, 10);
|
||||
}
|
||||
|
||||
function stopTimerInterval() {
|
||||
if (timerInterval !== undefined) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
drawTime();
|
||||
if (state.running) {
|
||||
setupTimerInterval();
|
||||
}
|
||||
|
||||
//Save our state when the app is closed
|
||||
E.on('kill', () => {
|
||||
storage.writeJSON(STATE_PATH, state);
|
||||
if (state.wasRunning) {
|
||||
storage.writeJSON(lapFile, lapHistory);
|
||||
}
|
||||
});
|
||||
|
||||
Bangle.loadWidgets();
|
||||
Bangle.drawWidgets();
|
|
@ -0,0 +1 @@
|
|||
require("heatshrink").decompress(atob("mEwwcCpMkyQCCB4oLFAQsf/4AC/ARvIYM//hHNAQIRBBxgRliQECCJgHFCJWJA4slCJEiDI+UIhYCFAw1IJ5NSAwtECI8/yVKNBYCC/5uGyIRHp/8AoMpCJvkCIyMGAQN/CISSECI+T/6kHPQ+f/IFD0gRL5IRP/4RHag8n/zaHCI8/+QRQfxARHYQQRNYQYROWAQ1OAQwR4YpACFa5YRbxIRLkoGDCKORCJcpCKuSCJYGFogRJpQGFpARJqQJGiQRIDY7aIagYCFSQyMEAQxoLJRQLG"))
|
After Width: | Height: | Size: 432 B |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 2.8 KiB |
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"id": "stlap",
|
||||
"name": "Stopwatch",
|
||||
"version": "0.03",
|
||||
"description": "A stopwatch that remembers its state, with a lap timer and a gesture mode (enable by swiping)",
|
||||
"icon": "icon.png",
|
||||
"type": "app",
|
||||
"tags": "tools,app",
|
||||
"supports": [
|
||||
"BANGLEJS2"
|
||||
],
|
||||
"allow_emulator": true,
|
||||
"storage": [
|
||||
{
|
||||
"name": "stlap.app.js",
|
||||
"url": "app.js"
|
||||
},
|
||||
{
|
||||
"name": "stlap.img",
|
||||
"url": "icon.js",
|
||||
"evaluate": true
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
0.01: New app!
|
||||
0.02: Submitted to the app loader
|
|
@ -0,0 +1,110 @@
|
|||
const storage = require("Storage");
|
||||
|
||||
Bangle.loadWidgets();
|
||||
Bangle.drawWidgets();
|
||||
|
||||
function pad(number) {
|
||||
return ('00' + parseInt(number)).slice(-2);
|
||||
}
|
||||
|
||||
function fileNameToDateString(fileName) {
|
||||
let timestamp = 0;
|
||||
let foundDigitYet = false;
|
||||
for (let character of fileName) {
|
||||
if ('1234567890'.includes(character)) {
|
||||
foundDigitYet = true;
|
||||
timestamp *= 10;
|
||||
timestamp += parseInt(character);
|
||||
} else if (foundDigitYet) break;
|
||||
}
|
||||
let date = new Date(timestamp);
|
||||
|
||||
let dayOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getDay()];
|
||||
let completed = storage.readJSON(fileName).final;
|
||||
|
||||
return `${dayOfWeek} ${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}` + (completed ? '' : ' (running)');
|
||||
}
|
||||
|
||||
function msToHumanReadable(ms) {
|
||||
|
||||
let hours = Math.floor(ms / 3600000);
|
||||
let minutes = Math.floor((ms % 3600000) / 60000);
|
||||
let seconds = Math.floor((ms % 60000) / 1000);
|
||||
let hundredths = Math.floor((ms % 1000) / 10);
|
||||
|
||||
return `${hours}:${pad(minutes)}:${pad(seconds)}:${pad(hundredths)}`;
|
||||
}
|
||||
|
||||
function view(fileName) {
|
||||
let lapTimes = [];
|
||||
let fileData = storage.readJSON(fileName).splits;
|
||||
for (let i = 0; i < fileData.length; i++) {
|
||||
if (i == 0) lapTimes.push(fileData[i]);
|
||||
else lapTimes.push(fileData[i] - fileData[i - 1]);
|
||||
}
|
||||
|
||||
let fastestIndex = 0;
|
||||
let slowestIndex = 0;
|
||||
for (let i = 0; i < lapTimes.length; i++) {
|
||||
if (lapTimes[i] < lapTimes[fastestIndex]) fastestIndex = i;
|
||||
else if (lapTimes[i] > lapTimes[slowestIndex]) slowestIndex = i;
|
||||
}
|
||||
|
||||
let lapMenu = {
|
||||
'': {
|
||||
'title': fileNameToDateString(fileName),
|
||||
'back': () => { E.showMenu(mainMenu); }
|
||||
},
|
||||
};
|
||||
lapMenu[`Total time: ${msToHumanReadable(fileData[fileData.length - 1])}`] = () => { };
|
||||
lapMenu[`Fastest lap: ${fastestIndex + 1}: ${msToHumanReadable(lapTimes[fastestIndex])}`] = () => { };
|
||||
lapMenu[`Slowest lap: ${slowestIndex + 1}: ${msToHumanReadable(lapTimes[slowestIndex])}`] = () => { };
|
||||
lapMenu[`Average lap: ${msToHumanReadable(fileData[fileData.length - 1] / fileData.length)}`] = () => { };
|
||||
|
||||
for (let i = 0; i < lapTimes.length; i++) {
|
||||
lapMenu[`Lap ${i + 1}: ${msToHumanReadable(lapTimes[i])}`] = () => { };
|
||||
}
|
||||
|
||||
lapMenu.Delete = () => {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Are you sure?',
|
||||
'back': () => { E.showMenu(lapMenu); }
|
||||
},
|
||||
'Yes': () => {
|
||||
storage.erase(fileName);
|
||||
showMainMenu();
|
||||
},
|
||||
'No': () => { E.showMenu(lapMenu); }
|
||||
});
|
||||
};
|
||||
|
||||
E.showMenu(lapMenu);
|
||||
}
|
||||
|
||||
function showMainMenu() {
|
||||
let LAP_FILES = storage.list(/stlap-[0-9]*\.json/);
|
||||
LAP_FILES.sort();
|
||||
LAP_FILES.reverse();
|
||||
|
||||
let mainMenu = {
|
||||
'': {
|
||||
'title': 'Sessions'
|
||||
}
|
||||
};
|
||||
|
||||
//I know eval is evil, but I can't think of any other way to do this.
|
||||
for (let lapFile of LAP_FILES) {
|
||||
mainMenu[fileNameToDateString(lapFile)] = eval(`(function() {
|
||||
view('${lapFile}');
|
||||
})`);
|
||||
}
|
||||
|
||||
if (LAP_FILES.length == 0) {
|
||||
mainMenu['No data'] = _ => { load(); };
|
||||
}
|
||||
|
||||
E.showMenu(mainMenu);
|
||||
}
|
||||
|
||||
showMainMenu();
|
|
@ -0,0 +1 @@
|
|||
require("heatshrink").decompress(atob("mEwwcCpMkyQCCB4oLFAQsf/4AC/ARvIYM//hHNAQIRBBxgRliQECCJgHFCJWJA4slCJEiDI+UIhYCFAw1IJ5NSAwtECI8/yVKNBOQYYMCpP/Nw2RAgYQBAAMP/gIBlIRHCAYAB8gRGRgVICIsESQwRCoANBA4OAAgIRGJgQRBSQWeCIck0gRFBYmf4AXDCI5BCkn/5ARGagSMBCIUn/xfBSQLaDCIiwD+SDBCJyVCCJrCCCJtPYQQROYQQ1OboYRKR4bdCCJChFCIShCCI7FDbooRCdJAXFa4wRBgAFBwARLIIIAFfY2JO4YAFNYMlQ4YRDkgSGCIuRAgaSBEAI7ChMpCJCzFgEBCImSCJEgCQPpBIlECI5NCjoJEpARISQMDPoYCBWAYCEL4V0BIjaDAQmeCI6SFAQMlkvACI5uGAYO4CJBKEBAWX//0yQ="))
|
After Width: | Height: | Size: 564 B |
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"id": "stlapview",
|
||||
"name": "Stopwatch laps",
|
||||
"version": "0.02",
|
||||
"description": "Optional lap viewer for my stopwatch app",
|
||||
"icon": "icon.png",
|
||||
"type": "app",
|
||||
"tags": "tools,app",
|
||||
"supports": [
|
||||
"BANGLEJS2"
|
||||
],
|
||||
"allow_emulator": true,
|
||||
"storage": [
|
||||
{
|
||||
"name": "stlapview.app.js",
|
||||
"url": "app.js"
|
||||
},
|
||||
{
|
||||
"name": "stlapview.img",
|
||||
"url": "icon.js",
|
||||
"evaluate": true
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"stlap": "app"
|
||||
}
|
||||
}
|