move changes to run to new app runplus

pull/2591/head
thyttan 2023-02-22 18:07:02 +01:00
parent 9824460add
commit 026b266686
9 changed files with 696 additions and 0 deletions

17
apps/runplus/ChangeLog Normal file
View File

@ -0,0 +1,17 @@
0.01: New App!
0.02: Set pace format to mm:ss, time format to h:mm:ss,
added settings to opt out of GPS and HRM
0.03: Fixed distance calculation, tested against Garmin Etrex, Amazfit GTS 2
0.04: Use the exstats module, and make what is displayed configurable
0.05: exstats updated so update 'distance' label is updated, option for 'speed'
0.06: Add option to record a run using the recorder app automatically
0.07: Fix crash if an odd number of active boxes are configured (fix #1473)
0.08: Added support for notifications from exstats. Support all stats from exstats
0.09: Fix broken start/stop if recording not enabled (fix #1561)
0.10: Don't allow the same setting to be chosen for 2 boxes (fix #1578)
0.11: Notifications fixes
0.12: Fix for recorder not stopping at end of run. Bug introduced in 0.11
0.13: Revert #1578 (stop duplicate entries) as with 2v12 menus it causes other boxes to be wiped (fix #1643)
0.14: Fix Bangle.js 1 issue where after the 'overwrite track' menu, the start/stop button stopped working
0.15: (beta) Swipe to intensity interface a la Karvonnen (curtesy of
FTeacher at https://github.com/f-teacher)

69
apps/runplus/README.md Normal file
View File

@ -0,0 +1,69 @@
# Run App
This app allows you to display the status of your run, it
shows distance, time, steps, cadence, pace and more.
To use it, start the app and press the middle button so that
the red `STOP` in the bottom right turns to a green `RUN`.
## Display
* `DIST` - the distance travelled based on the GPS (if you have a GPS lock).
* NOTE: this is based on the GPS coordinates which are not 100% accurate, especially initially. As
the GPS updates your position as it gets more satellites your position changes and the distance
shown will increase, even if you are standing still.
* `TIME` - the elapsed time for your run
* `PACE` - the number of minutes it takes you to run a given distance, configured in settings (default 1km) **based on your run so far**
* `HEART (BPM)` - Your current heart rate
* `Max BPM` - Your maximum heart rate reached during the run
* `STEPS` - Steps since you started exercising
* `CADENCE` - Steps per second based on your step rate *over the last minute*
* `GPS` - this is green if you have a GPS lock. GPS is turned on automatically
so if you have no GPS lock you just need to wait.
* The current time is displayed right at the bottom of the screen
* `RUN/STOP` - whether the distance for your run is being displayed or not
## Recording Tracks
When the `Recorder` app is installed, `Run` will automatically start and stop tracks
as needed, prompting you to overwrite or begin a new track if necessary.
## Settings
Under `Settings` -> `App` -> `Run` you can change settings for this app.
* `Record Run` (only displayed if `Recorder` app installed) should the Run app automatically
record GPS/HRM/etc data every time you start a run?
* `Pace` is the distance that pace should be shown over - 1km, 1 mile, 1/2 Marathon or 1 Marathon
* `Boxes` leads to a submenu where you can configure what is shown in each of the 6 boxes on the display.
Available stats are "Time", "Distance", "Steps", "Heart (BPM)", "Max BPM", "Pace (avg)", "Pace (curr)", "Speed", and "Cadence".
Any box set to "-" will display no information.
* Box 1 is the top left (defaults to "Distance")
* Box 2 is the top right (defaults to "Time")
* Box 3 is the middle left (defaults to "Pace (avg)")
* Box 4 is the middle right (defaults to "Heart (BPM)")
* Box 5 is the bottom left (defaults to "Steps")
* Box 6 is the bottom right (defaults to "Cadence")
* `Notifications` leads to a submenu where you can configure if the app will notify you after
your distance, steps, or time repeatedly pass your configured thresholds
* `Ntfy Dist`: The distance that you must pass before you are notified. Follows the `Pace` options
* "Off" (default), "1km", "1 mile", "1/2 Marathon", "1 Marathon"
* `Ntfy Steps`: The number of steps that must pass before you are notified.
* "Off" (default), 100, 500, 1000, 5000, 10000
* `Ntfy Time`: The amount of time that must pass before you are notified.
* "Off" (default), "30 sec", "1 min", "2 min", "5 min", "10 min", "30 min", "1 hour"
* `Dist Pattern`: The vibration pattern to use to notify you about meeting your distance threshold
* `Step Pattern`: The vibration pattern to use to notify you about meeting your step threshold
* `Time Pattern`: The vibration pattern to use to notify you about meeting your time threshold
## TODO
* Keep a log of each run's stats (distance/steps/etc)
## Development
This app uses the [`exstats` module](https://github.com/espruino/BangleApps/blob/master/modules/exstats.js). When uploaded via the
app loader, the module is automatically included in the app's source. However
when developing via the IDE the module won't get pulled in by default.
There are some options to fix this easily - please check out the [modules README.md file](https://github.com/espruino/BangleApps/blob/master/modules/README.md)

1
apps/runplus/app-icon.js Normal file
View File

@ -0,0 +1 @@
require("heatshrink").decompress(atob("mEw4UA///pH9vEFt9TIW0FqALJitUBZNVqoLqgo4BHZAUBtBTHgILB1XAEREV1WsEQ9AgWq1ALHgEO1WtBYxCBhWq0pdInWq2tABY8q1WVBZGq1XFBZS/IKQRvCDIsP9WsBZP60CTCBYs//+wLxALBTQ4AB///+AKHgYLB/gLK/4LHh//AIIwFitVr/8DIIwFLANXBAILIqogBn7DBEYrXBeQRgIBYKmHDgYLLZRBACBZYKJZIILKKRZeWgJGKAFQA=="))

194
apps/runplus/app.js Normal file
View File

@ -0,0 +1,194 @@
// Use widget utils to show/hide widgets
let wu = require("widget_utils");
let runInterval;
let karvonnenActive = false;
// Run interface wrapped in a function
let ExStats = require("exstats");
let B2 = process.env.HWVERSION===2;
let Layout = require("Layout");
let locale = require("locale");
let fontHeading = "6x8:2";
let fontValue = B2 ? "6x15:2" : "6x8:3";
let headingCol = "#888";
let fixCount = 0;
let isMenuDisplayed = false;
g.reset().clear();
Bangle.loadWidgets();
Bangle.drawWidgets();
wu.show();
// ---------------------------
let settings = Object.assign({
record: true,
B1: "dist",
B2: "time",
B3: "pacea",
B4: "bpm",
B5: "step",
B6: "caden",
paceLength: 1000,
notify: {
dist: {
value: 0,
notifications: [],
},
step: {
value: 0,
notifications: [],
},
time: {
value: 0,
notifications: [],
},
HRM: {
min: 65,
max: 170,
}
},
}, require("Storage").readJSON("run.json", 1) || {});
let statIDs = [settings.B1,settings.B2,settings.B3,settings.B4,settings.B5,settings.B6].filter(s=>s!=="");
let exs = ExStats.getStats(statIDs, settings);
// ---------------------------
// Called to start/stop running
function onStartStop() {
let running = !exs.state.active;
let prepPromises = [];
// start/stop recording
// Do this first in case recorder needs to prompt for
// an overwrite before we start tracking exstats
if (settings.record && WIDGETS["recorder"]) {
if (running) {
isMenuDisplayed = true;
prepPromises.push(
WIDGETS["recorder"].setRecording(true).then(() => {
isMenuDisplayed = false;
layout.setUI(); // grab our input handling again
layout.forgetLazyState();
layout.render();
})
);
} else {
prepPromises.push(
WIDGETS["recorder"].setRecording(false)
);
}
}
if (!prepPromises.length) // fix for Promise.all bug in 2v12
prepPromises.push(Promise.resolve());
Promise.all(prepPromises)
.then(() => {
if (running) {
exs.start();
} else {
exs.stop();
}
layout.button.label = running ? "STOP" : "START";
layout.status.label = running ? "RUN" : "STOP";
layout.status.bgCol = running ? "#0f0" : "#f00";
// if stopping running, don't clear state
// so we can at least refer to what we've done
layout.render();
});
}
let lc = [];
// Load stats in pair by pair
for (let i=0;i<statIDs.length;i+=2) {
let sa = exs.stats[statIDs[i+0]]
let sb = exs.stats[statIDs[i+1]];
lc.push({ type:"h", filly:1, c:[
sa?{type:"txt", font:fontHeading, label:sa.title.toUpperCase(), fillx:1, col:headingCol }:{},
sb?{type:"txt", font:fontHeading, label:sb.title.toUpperCase(), fillx:1, col:headingCol }:{}
]}, { type:"h", filly:1, c:[
sa?{type:"txt", font:fontValue, label:sa.getString(), id:sa.id, fillx:1 }:{},
sb?{type:"txt", font:fontValue, label:sb.getString(), id:sb.id, fillx:1 }:{}
]});
if (sa) sa.on('changed', e=>layout[e.id].label = e.getString());
if (sb) sb.on('changed', e=>layout[e.id].label = e.getString());
}
// At the bottom put time/GPS state/etc
lc.push({ type:"h", filly:1, c:[
{type:"txt", font:fontHeading, label:"GPS", id:"gps", fillx:1, bgCol:"#f00" },
{type:"txt", font:fontHeading, label:"00:00", id:"clock", fillx:1, bgCol:g.theme.fg, col:g.theme.bg },
{type:"txt", font:fontHeading, label:"STOP", id:"status", fillx:1 }
]});
// Now calculate the layout
let layout = new Layout( {
type:"v", c: lc
},{lazy:true, btns:[{ label:"START", cb: ()=>{if (karvonnenActive) {stopKarvonnenUI();run();} onStartStop();}, id:"button"}]});
delete lc;
layout.render();
function configureNotification(stat) {
stat.on('notify', (e)=>{
settings.notify[e.id].notifications.reduce(function (promise, buzzPattern) {
return promise.then(function () {
return Bangle.buzz(buzzPattern[0], buzzPattern[1]);
});
}, Promise.resolve());
});
}
Object.keys(settings.notify).forEach((statType) => {
if (settings.notify[statType].increment > 0 && exs.stats[statType]) {
configureNotification(exs.stats[statType]);
}
});
// Handle GPS state change for icon
Bangle.on("GPS", function(fix) {
layout.gps.bgCol = fix.fix ? "#0f0" : "#f00";
if (!fix.fix) return; // only process actual fixes
if (fixCount++ === 0) {
Bangle.buzz(); // first fix, does not need to respect quiet mode
}
});
// run() function used to switch between traditional run UI and karvonnen UI
function run() {
wu.show();
layout.lazy = false;
layout.render();
layout.lazy = true;
// We always call ourselves once a second to update
if (!runInterval){
runInterval = setInterval(function() {
layout.clock.label = locale.time(new Date(),1);
if (!isMenuDisplayed && !karvonnenActive) layout.render();
}, 1000);
}
}
run();
///////////////////////////////////////////////
// Karvonnen
///////////////////////////////////////////////
function stopRunUI() {
// stop updating and drawing the traditional run app UI
clearInterval(runInterval);
runInterval = undefined;
karvonnenActive = true;
}
function stopKarvonnenUI() {
g.reset().clear();
clearInterval(karvonnenInterval);
karvonnenInterval = undefined;
karvonnenActive = false;
}
let karvonnenInterval;
// Define the function to go back and forth between the different UI's
function swipeHandler(LR,_) {
if (LR==-1 && karvonnenActive && !isMenuDisplayed) {stopKarvonnenUI(); run();}
if (LR==1 && !karvonnenActive && !isMenuDisplayed) {stopRunUI(); karvonnenInterval = eval(require("Storage").read("run_karvonnen"))(settings.HRM, exs.stats.bpm);}
}
// Listen for swipes with the swipeHandler
Bangle.on("swipe", swipeHandler);

BIN
apps/runplus/app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

216
apps/runplus/karvonnen.js Normal file
View File

@ -0,0 +1,216 @@
(function karvonnen(hrmSettings, exsHrmStats) {
//This app is an extra feature implementation for the Run.app of the bangle.js. It's called run+
//The calculation of the Heart Rate Zones is based on the Karvonnen method. It requires to know maximum and minimum heart rates. More precise calculation methods require a lab.
//Other methods are even more approximative.
let wu = require("widget_utils");
wu.hide();
let R = Bangle.appRect;
g.reset().clearRect(R).setFontAlign(0,0,0);
const x = "x"; const y = "y";
function Rdiv(axis, divisor) { // Used when placing things on the screen
return axis=="x" ? (R.x + (R.w-1)/divisor):(R.y + (R.h-1)/divisor);
}
let linePoints = { //Not lists of points, but used to update points in the drawArrows function.
x: [
175/40,
2,
175/135,
],
y: [
175/64,
175/52,
175/110,
175/122,
],
};
function drawArrows() {
g.setColor(g.theme.fg);
// Upper
g.drawLine(Rdiv(x,linePoints.x[0]), Rdiv(y,linePoints.y[0]), Rdiv(x,linePoints.x[1]), Rdiv(y,linePoints.y[1]));
g.drawLine(Rdiv(x,linePoints.x[1]), Rdiv(y,linePoints.y[1]), Rdiv(x,linePoints.x[2]), Rdiv(y,linePoints.y[0]));
// Lower
g.drawLine(Rdiv(x,linePoints.x[0]), Rdiv(y,linePoints.y[2]), Rdiv(x,linePoints.x[1]), Rdiv(y,linePoints.y[3]));
g.drawLine(Rdiv(x,linePoints.x[1]), Rdiv(y,linePoints.y[3]), Rdiv(x,linePoints.x[2]), Rdiv(y,linePoints.y[2]));
}
//To calculate Heart rate zones, we need to know the heart rate reserve (HRR)
// HRR = maximum HR - Minimum HR. minhr is minimum hr, maxhr is maximum hr.
//get the hrr (heart rate reserve).
// I put random data here, but this has to come as a menu in the settings section so that users can change it.
let minhr = hrmSettings.min;
let maxhr = hrmSettings.max;
function calculatehrr(minhr, maxhr) {
return maxhr - minhr;}
//test input for hrr (it works).
let hrr = calculatehrr(minhr, maxhr);
console.log(hrr);
//Test input to verify the zones work. The following value for "hr" has to be deleted and replaced with the Heart Rate Monitor input.
let hr = exsHrmStats.getValue();
let hr1 = hr;
// These letiables display next and previous HR zone.
//get the hrzones right. The calculation of the Heart rate zones here is based on the Karvonnen method
//60-70% of HRR+minHR = zone2. //70-80% of HRR+minHR = zone3. //80-90% of HRR+minHR = zone4. //90-99% of HRR+minHR = zone5. //=>99% of HRR+minHR = serious risk of heart attack
let minzone2 = hrr * 0.6 + minhr;
let maxzone2 = hrr * 0.7 + minhr;
let maxzone3 = hrr * 0.8 + minhr;
let maxzone4 = hrr * 0.9 + minhr;
let maxzone5 = hrr * 0.99 + minhr;
// HR data: large, readable, in the middle of the screen
function drawHR() {
g.setFontAlign(-1,0,0);
g.clearRect(Rdiv(x,11/4),Rdiv(y,2)-25,Rdiv(x,11/4)+50*2,Rdiv(y,2)+25);
g.setColor(g.theme.fg);
g.setFont("Vector",50);
g.drawString(hr, Rdiv(x,11/4), Rdiv(y,2)+4);
}
function drawWaitHR() {
g.setColor(g.theme.fg);
// Waiting for HRM
g.setFontAlign(0,0,0);
g.setFont("Vector",50);
g.drawString("--", Rdiv(x,2)+4, Rdiv(y,2)+4);
// Waiting for current Zone
g.setFont("Vector",24);
g.drawString("Z-", Rdiv(x,4.3), Rdiv(y,2)+2);
// waiting for upper and lower limit of current zone
g.setFont("Vector",20);
g.drawString("--", Rdiv(x,2)+2, Rdiv(y,9/2));
g.drawString("--", Rdiv(x,2)+2, Rdiv(y,9/7));
}
//These functions call arcs to show different HR zones.
//To shorten the code, I'll reference some letiables and reuse them.
let centreX = R.x + 0.5 * R.w;
let centreY = R.y + 0.5 * R.h;
let minRadius = 0.38 * R.h;
let maxRadius = 0.50 * R.h;
//draw background image (dithered green zones)(I should draw different zones in different dithered colors)
const HRzones= require("graphics_utils");
let minRadiusz = 0.44 * R.h;
let startAngle = HRzones.degreesToRadians(-88.5);
let endAngle = HRzones.degreesToRadians(268.5);
function drawBgArc() {
g.setColor(g.theme.dark==false?0xC618:"#002200");
HRzones.fillArc(g, centreX, centreY, minRadiusz, maxRadius, startAngle, endAngle);
}
const zones = require("graphics_utils");
//####### A function to simplify a bit the code ######
function simplify (sA, eA, Z, currentZone, lastZone) {
let startAngle = zones.degreesToRadians(sA);
let endAngle = zones.degreesToRadians(eA);
if (currentZone == lastZone) zones.fillArc(g, centreX, centreY, minRadius, maxRadius, startAngle, endAngle);
else zones.fillArc(g, centreX, centreY, minRadiusz, maxRadius, startAngle, endAngle);
g.setFont("Vector",24);
g.clearRect(Rdiv(x,4.3)-12, Rdiv(y,2)+2-12,Rdiv(x,4.3)+12, Rdiv(y,2)+2+12);
g.setFontAlign(0,0,0);
g.drawString(Z, Rdiv(x,4.3), Rdiv(y,2)+2);
}
function zoning (max, min) { // draw values of upper and lower limit of current zone
g.setFont("Vector",20);
g.setColor(g.theme.fg);
g.clearRect(Rdiv(x,2)-20*2, Rdiv(y,9/2)-10,Rdiv(x,2)+20*2, Rdiv(y,9/2)+10);
g.clearRect(Rdiv(x,2)-20*2, Rdiv(y,9/7)-10,Rdiv(x,2)+20*2, Rdiv(y,9/7)+10);
g.setFontAlign(0,0,0);
g.drawString(max, Rdiv(x,2), Rdiv(y,9/2));
g.drawString(min, Rdiv(x,2), Rdiv(y,9/7));
}
function clearCurrentZone() { // Clears the extension of the current zone by painting the extension area in background color
g.setColor(g.theme.bg);
HRzones.fillArc(g, centreX, centreY, minRadius-1, minRadiusz, startAngle, endAngle);
}
function getZone(zone) {
drawBgArc();
clearCurrentZone();
if (zone >= 0) {zoning(minzone2, minhr);g.setColor("#00ffff");simplify(-88.5, -45, "Z1", 0, zone);}
if (zone >= 1) {zoning(maxzone2, minzone2);g.setColor("#00ff00");simplify(-43.5, -21.5, "Z2", 1, zone);}
if (zone >= 2) {zoning(maxzone2, minzone2);g.setColor("#00ff00");simplify(-20, 1.5, "Z2", 2, zone);}
if (zone >= 3) {zoning(maxzone2, minzone2);g.setColor("#00ff00");simplify(3, 24, "Z2", 3, zone);}
if (zone >= 4) {zoning(maxzone3, maxzone2);g.setColor("#ffff00");simplify(25.5, 46.5, "Z3", 4, zone);}
if (zone >= 5) {zoning(maxzone3, maxzone2);g.setColor("#ffff00");simplify(48, 69, "Z3", 5, zone);}
if (zone >= 6) {zoning(maxzone3, maxzone2);g.setColor("#ffff00");simplify(70.5, 91.5, "Z3", 6, zone);}
if (zone >= 7) {zoning(maxzone4, maxzone3);g.setColor("#ff8000");simplify(93, 114.5, "Z4", 7, zone);}
if (zone >= 8) {zoning(maxzone4, maxzone3);g.setColor("#ff8000");simplify(116, 137.5, "Z4", 8, zone);}
if (zone >= 9) {zoning(maxzone4, maxzone3);g.setColor("#ff8000");simplify(139, 160, "Z4", 9, zone);}
if (zone >= 10) {zoning(maxzone5, maxzone4);g.setColor("#ff0000");simplify(161.5, 182.5, "Z5", 10, zone);}
if (zone >= 11) {zoning(maxzone5, maxzone4);g.setColor("#ff0000");simplify(184, 205, "Z5", 11, zone);}
if (zone == 12) {zoning(maxzone5, maxzone4);g.setColor("#ff0000");simplify(206.5, 227.5, "Z5", 12, zone);}
}
function getZoneAlert() {
const HRzonemax = require("graphics_utils");
let centreX1,centreY1,maxRadius1 = 1;
let minRadius = 0.40 * R.h;
let startAngle1 = HRzonemax.degreesToRadians(-90);
let endAngle1 = HRzonemax.degreesToRadians(270);
g.setFont("Vector",38);g.setColor("#ff0000");
HRzonemax.fillArc(g, centreX, centreY, minRadius, maxRadius, startAngle1, endAngle1);
g.drawString("ALERT", 26,66);
}
//Subdivided zones for better readability of zones when calling the images. //Changing HR zones will trigger the function with the image and previous&next HR zones.
let subZoneLast;
function drawZones() {
if ((hr < maxhr - 2) && subZoneLast==13) {g.clear(); drawArrows(); drawHR();} // Reset UI when coming down from zone alert.
if (hr <= hrr * 0.6 + minhr) {if (subZoneLast!=0) {subZoneLast=0; getZone(subZoneLast);}} // Z1
else if (hr <= hrr * 0.64 + minhr) {if (subZoneLast!=1) {subZoneLast=1; getZone(subZoneLast);}} // Z2a
else if (hr <= hrr * 0.67 + minhr) {if (subZoneLast!=2) {subZoneLast=2; getZone(subZoneLast);}} // Z2b
else if (hr <= hrr * 0.70 + minhr) {if (subZoneLast!=3) {subZoneLast=3; getZone(subZoneLast);}} // Z2c
else if (hr <= hrr * 0.74 + minhr) {if (subZoneLast!=4) {subZoneLast=4; getZone(subZoneLast);}} // Z3a
else if (hr <= hrr * 0.77 + minhr) {if (subZoneLast!=5) {subZoneLast=5; getZone(subZoneLast);}} // Z3b
else if (hr <= hrr * 0.80 + minhr) {if (subZoneLast!=6) {subZoneLast=6; getZone(subZoneLast);}} // Z3c
else if (hr <= hrr * 0.84 + minhr) {if (subZoneLast!=7) {subZoneLast=7; getZone(subZoneLast);}} // Z4a
else if (hr <= hrr * 0.87 + minhr) {if (subZoneLast!=8) {subZoneLast=8; getZone(subZoneLast);}} // Z4b
else if (hr <= hrr * 0.90 + minhr) {if (subZoneLast!=9) {subZoneLast=9; getZone(subZoneLast);}} // Z4c
else if (hr <= hrr * 0.94 + minhr) {if (subZoneLast!=10) {subZoneLast=10; getZone(subZoneLast);}} // Z5a
else if (hr <= hrr * 0.96 + minhr) {if (subZoneLast!=11) {subZoneLast=11; getZone(subZoneLast);}} // Z5b
else if (hr <= hrr * 0.98 + minhr) {if (subZoneLast!=12) {subZoneLast=12; getZone(subZoneLast);}} // Z5c
else if (hr >= maxhr - 2) {subZoneLast=13; g.clear();getZoneAlert();} // Alert
}
function initDraw() {
drawArrows();
drawWaitHR();
drawBgArc();
//drawZones();
}
let hrLast;
//h = 0; // Used to force hr update to trigger draws, together with `if (h!=0) hr = h;` below.
function updateUI() { // Update UI, only draw if warranted by change in HR.
hrLast = hr;
hr = exsHrmStats.getValue();
//if (h!=0) hr = h;
if (hr!=hrLast) {
drawHR();
drawZones();
} //g.setColor(g.theme.fg).drawLine(175/2,0,175/2,175).drawLine(0,175/2,175,175/2); // Used to align UI elements.
}
initDraw();
// check for updates every second.
karvonnenInterval = setInterval(function() {
if (!isMenuDisplayed && karvonnenActive) updateUI();
}, 1000);
return karvonnenInterval;
})

View File

@ -0,0 +1,42 @@
{
"id": "run",
"name": "Run",
"version": "0.15",
"description": "Displays distance, time, steps, cadence, pace and more for runners.",
"icon": "app.png",
"tags": "run,running,fitness,outdoors,gps",
"supports": [
"BANGLEJS",
"BANGLEJS2"
],
"screenshots": [
{
"url": "screenshot.png"
}
],
"readme": "README.md",
"storage": [
{
"name": "run.app.js",
"url": "app.js"
},
{
"name": "run.img",
"url": "app-icon.js",
"evaluate": true
},
{
"name": "run.settings.js",
"url": "settings.js"
},
{
"name": "run_karvonnen",
"url": "karvonnen.js"
}
],
"data": [
{
"name": "run.json"
}
]
}

BIN
apps/runplus/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

157
apps/runplus/settings.js Normal file
View File

@ -0,0 +1,157 @@
(function(back) {
const SETTINGS_FILE = "run.json";
var ExStats = require("exstats");
var statsList = ExStats.getList();
statsList.unshift({name:"-",id:""}); // add blank menu item
var statsIDs = statsList.map(s=>s.id);
// ...and overwrite them with any saved values
// This way saved values are preserved if a new version adds more settings
const storage = require('Storage')
let settings = Object.assign({
record: true,
B1: "dist",
B2: "time",
B3: "pacea",
B4: "bpm",
B5: "step",
B6: "caden",
paceLength: 1000, // TODO: Default to either 1km or 1mi based on locale
notify: {
dist: {
increment: 0,
notifications: [],
},
step: {
increment: 0,
notifications: [],
},
time: {
increment: 0,
notifications: [],
},
},
HRM: {
min: 65,
max: 165,
},
}, storage.readJSON(SETTINGS_FILE, 1) || {});
function saveSettings() {
storage.write(SETTINGS_FILE, settings)
}
function getBoxChooser(boxID) {
return {
min: 0, max: statsIDs.length-1,
value: Math.max(statsIDs.indexOf(settings[boxID]),0),
format: v => statsList[v].name,
onchange: v => {
settings[boxID] = statsIDs[v];
saveSettings();
},
}
}
function sampleBuzz(buzzPatterns) {
return buzzPatterns.reduce(function (promise, buzzPattern) {
return promise.then(function () {
return Bangle.buzz(buzzPattern[0], buzzPattern[1]);
});
}, Promise.resolve());
}
var menu = {
'': { 'title': 'Run' },
'< Back': back,
};
if (global.WIDGETS&&WIDGETS["recorder"])
menu[/*LANG*/"Record Run"] = {
value : !!settings.record,
onchange : v => {
settings.record = v;
saveSettings();
}
};
var notificationsMenu = {
'< Back': function() { E.showMenu(menu) },
}
menu[/*LANG*/"Notifications"] = function() { E.showMenu(notificationsMenu)};
ExStats.appendMenuItems(menu, settings, saveSettings);
ExStats.appendNotifyMenuItems(notificationsMenu, settings, saveSettings);
var vibPatterns = [/*LANG*/"Off", ".", "-", "--", "-.-", "---"];
var vibTimes = [
[],
[[100, 1]],
[[300, 1]],
[[300, 1], [300, 0], [300, 1]],
[[300, 1],[300, 0], [100, 1], [300, 0], [300, 1]],
[[300, 1],[300, 0],[300, 1],[300, 0],[300, 1]],
];
notificationsMenu[/*LANG*/"Dist Pattern"] = {
value: Math.max(0,vibTimes.findIndex((p) => JSON.stringify(p) === JSON.stringify(settings.notify.dist.notifications))),
min: 0, max: vibTimes.length - 1,
format: v => vibPatterns[v]||/*LANG*/"Off",
onchange: v => {
settings.notify.dist.notifications = vibTimes[v];
sampleBuzz(vibTimes[v]);
saveSettings();
}
}
notificationsMenu[/*LANG*/"Step Pattern"] = {
value: Math.max(0,vibTimes.findIndex((p) => JSON.stringify(p) === JSON.stringify(settings.notify.step.notifications))),
min: 0, max: vibTimes.length - 1,
format: v => vibPatterns[v]||/*LANG*/"Off",
onchange: v => {
settings.notify.step.notifications = vibTimes[v];
sampleBuzz(vibTimes[v]);
saveSettings();
}
}
notificationsMenu[/*LANG*/"Time Pattern"] = {
value: Math.max(0,vibTimes.findIndex((p) => JSON.stringify(p) === JSON.stringify(settings.notify.time.notifications))),
min: 0, max: vibTimes.length - 1,
format: v => vibPatterns[v]||/*LANG*/"Off",
onchange: v => {
settings.notify.time.notifications = vibTimes[v];
sampleBuzz(vibTimes[v]);
saveSettings();
}
}
var boxMenu = {
'< Back': function() { E.showMenu(menu) },
}
Object.assign(boxMenu,{
'Box 1': getBoxChooser("B1"),
'Box 2': getBoxChooser("B2"),
'Box 3': getBoxChooser("B3"),
'Box 4': getBoxChooser("B4"),
'Box 5': getBoxChooser("B5"),
'Box 6': getBoxChooser("B6"),
});
menu[/*LANG*/"Boxes"] = function() { E.showMenu(boxMenu)};
var hrmMenu = {
'< Back': function() { E.showMenu(menu) },
}
menu[/*LANG*/"HRM min/max"] = function() { E.showMenu(hrmMenu)};
hrmMenu[/*LANG*/"min"] = {
min: 1, max: 100,
value: settings.HRM.min,
format: w => w,
onchange: w => {
settings.HRM.min = w;
saveSettings();
},
}
hrmMenu[/*LANG*/"max"] = {
min: 120, max: 190,
value: settings.HRM.max,
format: v => v,
onchange: v => {
settings.HRM.max = v;
saveSettings();
},
}
E.showMenu(menu);
})