forked from FOSS/BangleApps
Added app 'Rebble Agenda'
parent
5213110807
commit
7d63601355
|
@ -0,0 +1 @@
|
||||||
|
0.01: Initial version
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Rebble Agenda
|
||||||
|
|
||||||
|
Agenda app for showing upcoming events in an animated fashion.
|
||||||
|
Heavily inspired by the inbuilt agenda of the pebble time.
|
||||||
|
Switch between calendar events by swiping up or down. Click the button to exit.
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
- *Use system theme* - Use the colors of the system theme. Otherwise use following colors.
|
||||||
|
- *Accent* - The color of the rightmost accent bar if not following system theme.
|
||||||
|
- *Background* - The background color to use if not following system theme.
|
||||||
|
- *Foreground* - The foreground color to use if not following system theme.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The weather icon in the top right corner is currently just showing the current weather as provided by [weather](https://github.com/espruino/BangleApps/blob/master/apps/weather/). Closest forecast to be implemented in a future release.
|
||||||
|
- Events only show as much of their title and description as can be fit on the screen, which is one and four (wrapped) lines respectively.
|
||||||
|
- Events are loaded from ```android.calendar.json```, which is read in its entirety. If you have a very busy schedule, loading may take a second or two.
|
||||||
|
|
||||||
|
## Creator
|
||||||
|
|
||||||
|
- [Sarah Alrøe](https://github.com/SarahAlroe), August+September 2023
|
|
@ -0,0 +1 @@
|
||||||
|
require("heatshrink").decompress(atob("mEwxH+AH4A/ADscjgRhDhgePCKIv1hgAEDoYJFAA4RJFyQvcGBYRGlIdDlIuLCJgvQggdDggvLCJgv/PoOgDgOgR5oRLF6AeBgkEFxgRNF6IAdF/4vpjwAkF/4v/F/4vRjgAEA6Iv/F/4v/F/4RHADov/Q6MMAAgv/F/4v/F/4vJADov/F/4v/F5IwkFxQwjFxgA/AH4A/AH4AZA"))
|
|
@ -0,0 +1,583 @@
|
||||||
|
{
|
||||||
|
/* Requires */
|
||||||
|
const weather = require('weather');
|
||||||
|
require("Font6x12").add(Graphics);
|
||||||
|
require("Font8x16").add(Graphics);
|
||||||
|
const SETTINGS_FILE = "rebbleagenda.json";
|
||||||
|
const settings = require("Storage").readJSON(SETTINGS_FILE, 1) || {'system':true, 'bg': '#fff','fg': '#000','acc': '#0FF'};
|
||||||
|
|
||||||
|
/* Layout consts */
|
||||||
|
const MARKER_SIZE = 4;
|
||||||
|
const BORDER_SIZE = 6;
|
||||||
|
const WIDGET_SIZE = 24;
|
||||||
|
const PRIMARY_OFFSET = WIDGET_SIZE + BORDER_SIZE + MARKER_SIZE - 20 / 2;
|
||||||
|
const SECONDARY_OFFSET = g.getHeight() - WIDGET_SIZE - 16 - 20;
|
||||||
|
const MARKER_POS_UPPER = Uint8Array([g.getWidth() - BORDER_SIZE - MARKER_SIZE, WIDGET_SIZE + BORDER_SIZE + MARKER_SIZE]);
|
||||||
|
const PIN_SIZE = 10;
|
||||||
|
const ACCENT_WIDTH = 2 * BORDER_SIZE + 2 * MARKER_SIZE; // <20>=2r, borders each side.
|
||||||
|
|
||||||
|
const TEXT_COLOR = settings.system?g.theme.fg:settings.fg;
|
||||||
|
const BG_COLOR = settings.system?g.theme.bg:settings.bg;
|
||||||
|
const ACCENT_COLOR = settings.system?g.theme.bgH:settings.acc;
|
||||||
|
const SUN_COLOR_START = 0xF800;
|
||||||
|
const SUN_COLOR_END = 0xFFE0;
|
||||||
|
const SUN_FACE = 0x0000;
|
||||||
|
|
||||||
|
/* Animation polygon sets*/
|
||||||
|
const CLEAR_POLYS_1 = [
|
||||||
|
new Uint8Array([0, 176, 0, 0, 176, 0, 176, 0, 0, 0, 0, 176]),
|
||||||
|
new Uint8Array([0, 176, 0, 0, 176, 0, 170, 7, 10, 12, 7, 168]),
|
||||||
|
new Uint8Array([0, 176, 0, 0, 176, 0, 139, 49, 41, 45, 43, 125]),
|
||||||
|
new Uint8Array([0, 176, 0, 0, 176, 0, 90, 81, 82, 86, 85, 94]),
|
||||||
|
new Uint8Array([0, 176, 0, 0, 176, 0, 91, 85, 85, 85, 85, 91])
|
||||||
|
];
|
||||||
|
|
||||||
|
const CLEAR_POLYS_2 = [
|
||||||
|
new Uint8Array([0, 176, 176, 176, 176, 0, 176, 0, 176, 176, 0, 176]),
|
||||||
|
new Uint8Array([0, 176, 176, 176, 176, 0, 170, 7, 162, 161, 7, 168]),
|
||||||
|
new Uint8Array([0, 176, 176, 176, 176, 0, 139, 49, 130, 126, 43, 125]),
|
||||||
|
new Uint8Array([0, 176, 176, 176, 176, 0, 90, 81, 95, 89, 85, 94]),
|
||||||
|
new Uint8Array([0, 176, 176, 176, 176, 0, 91, 85, 91, 91, 85, 91])
|
||||||
|
];
|
||||||
|
|
||||||
|
const BREATHING_POLYS = [
|
||||||
|
new Uint8Array([72, 88, 78, 78, 78, 78, 78, 78, 78, 78, 78, 78, 78, 78, 84, 88]),
|
||||||
|
new Uint8Array([63, 88, 64, 73, 78, 73, 78, 73, 78, 73, 78, 73, 92, 73, 93, 88]),
|
||||||
|
new Uint8Array([60, 88, 56, 76, 78, 60, 78, 60, 78, 60, 78, 60, 100, 76, 96, 88]),
|
||||||
|
new Uint8Array([56, 88, 50, 78, 64, 54, 78, 54, 78, 54, 92, 54, 106, 78, 100, 88]),
|
||||||
|
new Uint8Array([53, 88, 47, 80, 52, 53, 78, 41, 78, 41, 104, 53, 109, 80, 103, 88]),
|
||||||
|
new Uint8Array([50, 88, 43, 81, 43, 51, 63, 32, 92, 32, 113, 51, 113, 81, 106, 88])];
|
||||||
|
const SUN_EYE_LEFT_POLY = new Uint8Array([56, 52, 64, 44, 72, 52, 72, 55, 69, 54, 64, 50, 58, 55, 56, 55]);
|
||||||
|
const SUN_EYE_RIGHT_OFFSET = 30;
|
||||||
|
const MOUTH_POLY = new Uint8Array([78, 77, 68, 75, 67, 73, 69, 71, 78, 73, 87, 71, 89, 73, 88, 75]);
|
||||||
|
|
||||||
|
/* Animation timings */
|
||||||
|
const TIME_CLEAR_ANIM = 400;
|
||||||
|
const TIME_CLEAR_BREAK = 10;
|
||||||
|
const TIME_DEFAULT_ANIM = 300;
|
||||||
|
const TIME_BUMP_ANIM = 200;
|
||||||
|
const TIME_EXIT_ANIM = 500;
|
||||||
|
const TIME_EVENT_CHANGE = 150;
|
||||||
|
const TIME_EVENT_BREAK_IN = 300;
|
||||||
|
const TIME_EVENT_BREAK_ANIM = 800;
|
||||||
|
const TIME_EVENT_BREAK_HALT = 500;
|
||||||
|
const TIME_EVENT_BREAK_OUT = 500;
|
||||||
|
|
||||||
|
/* Utility functions */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two dates occur on the same day
|
||||||
|
* @param {Date} d1 The first date to compare
|
||||||
|
* @param {Date} d2 The second date to compare
|
||||||
|
* @returns {Boolean} The two dates are on the same day
|
||||||
|
*/
|
||||||
|
const isSameDay = function (d1, d2) {
|
||||||
|
return (d1.getDate() == d2.getDate() && d1.getMonth() == d2.getMonth() && d1.getFullYear() == d2.getFullYear());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply sinusoidal easing to a value 0-1
|
||||||
|
* @param {Number} x Number to ease
|
||||||
|
* @returns {Number} Ease of x
|
||||||
|
*/
|
||||||
|
const ease = function (x) {
|
||||||
|
"jit";
|
||||||
|
return 1 - (Math.cos(Math.PI * x) + 1) / 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map from 0-1 to a number interval
|
||||||
|
* @param {Number} outMin Minimum output number
|
||||||
|
* @param {Number} outMax Maximum output number
|
||||||
|
* @param {Number} x Number between 0 and 1 to map from
|
||||||
|
* @returns {Number} x mapped between min and max
|
||||||
|
*/
|
||||||
|
const map = function (outMin, outMax, x) {
|
||||||
|
"jit";
|
||||||
|
return outMin + x * (outMax - outMin);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return [0-1] progress through an interval
|
||||||
|
* @param {Number} start When the interval was started in ms
|
||||||
|
* @param {Number} end When the interval is supposed to stop in ms
|
||||||
|
* @returns {Number} Value between 0 and 1 reflecting progress through interval
|
||||||
|
*/
|
||||||
|
const timeProgress = function (start, end) {
|
||||||
|
"jit";
|
||||||
|
const length = end - start;
|
||||||
|
const delta = Date.now() - start;
|
||||||
|
return Math.min(Math.max(delta / length, 0), 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolate between sets of polygon coordinates
|
||||||
|
* @param {Array} polys An array of arrays, each containing an equally long set of coordinates
|
||||||
|
* @param {Number} pos Progress through interpolation [0-1]
|
||||||
|
* @returns {Array} Interpolation between the two closest sets of coordinates
|
||||||
|
*/
|
||||||
|
const interpolatePoly = function (polys, pos) {
|
||||||
|
const span = polys.length - 1;
|
||||||
|
pos = pos * span;
|
||||||
|
pos = pos > span ? span : pos;
|
||||||
|
const upper = polys[Math.ceil(pos)];
|
||||||
|
const lower = polys[Math.floor(Math.max(pos - 0.000001, 0))];
|
||||||
|
const interp = pos - Math.floor(pos - 0.000001);
|
||||||
|
return upper.map((up, i) => {
|
||||||
|
return Math.round(up * interp + lower[i] * (1 - interp));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repeatedly call callback with progress through an interval of length time
|
||||||
|
* @param {Function} anim Callback which takes i, animation progress [0-1]
|
||||||
|
* @param {Number} time How many ms the animation should last
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const doAnim = function (anim, time) {
|
||||||
|
const animStart = Date.now();
|
||||||
|
const animEnd = animStart + time;
|
||||||
|
let i = 0;
|
||||||
|
do {
|
||||||
|
i = timeProgress(animStart, animEnd);
|
||||||
|
anim(i);
|
||||||
|
} while (i < 1);
|
||||||
|
anim(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Screen draw functions */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw an event
|
||||||
|
* @param {Number} index Index in the events array of event to draw
|
||||||
|
* @param {Number} yOffset Vertical pixel offset of the draw
|
||||||
|
* @param {Boolean} drawSecondary Should secondary event be drawn if possible?
|
||||||
|
*/
|
||||||
|
const drawEvent = function (index, yOffset, drawSecondary) {
|
||||||
|
g.setColor(TEXT_COLOR);
|
||||||
|
// Draw the event time
|
||||||
|
g.setFontAlign(-1, -1, 0);
|
||||||
|
g.setFont("Vector", 20);
|
||||||
|
g.drawString(events[index].time, BORDER_SIZE, PRIMARY_OFFSET + yOffset);
|
||||||
|
|
||||||
|
// Draw the event title
|
||||||
|
g.setFont("8x16");
|
||||||
|
g.drawString(events[index].title, BORDER_SIZE, PRIMARY_OFFSET + 20 + yOffset);
|
||||||
|
|
||||||
|
// And the event description
|
||||||
|
g.setFont("6x12");
|
||||||
|
g.drawString(events[index].description, BORDER_SIZE, PRIMARY_OFFSET + 20 + 12 + 2 + yOffset);
|
||||||
|
|
||||||
|
// Draw a secondary event if asked to and exists
|
||||||
|
if (drawSecondary) {
|
||||||
|
if (index + 1 < events.length) {
|
||||||
|
if (events[index].date != events[index + 1].date) {
|
||||||
|
// If event belongs to another day, draw circle
|
||||||
|
g.fillCircle((g.getWidth() - ACCENT_WIDTH) / 2, g.getHeight() - MARKER_SIZE - WIDGET_SIZE - BORDER_SIZE + yOffset, MARKER_SIZE);
|
||||||
|
} else {
|
||||||
|
// Draw event time and title
|
||||||
|
g.setFont("Vector", 20);
|
||||||
|
g.drawString(events[index + 1].time, BORDER_SIZE, SECONDARY_OFFSET + yOffset);
|
||||||
|
g.setFont("8x16");
|
||||||
|
g.drawString(events[index + 1].title, BORDER_SIZE, SECONDARY_OFFSET + 20 + yOffset);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no more events exist, draw end
|
||||||
|
g.setFontAlign(0, 1, 0);
|
||||||
|
g.setFont("Vector", 20);
|
||||||
|
g.drawString("End", (g.getWidth() - ACCENT_WIDTH) / 2, g.getHeight() - BORDER_SIZE + yOffset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a two-line caption beneath a figure (Just beneath centre)
|
||||||
|
* @param {String} first Top string to draw
|
||||||
|
* @param {String} second Bottom string to draw
|
||||||
|
* @param {Number} yOffset Vertical pixel offset of the draw
|
||||||
|
*/
|
||||||
|
const drawFigureCaption = function (first, second, yOffset) {
|
||||||
|
g.setFontAlign(0, -1, 0);
|
||||||
|
g.setFont("Vector", 18);
|
||||||
|
g.setColor(TEXT_COLOR);
|
||||||
|
g.drawString(first, (g.getWidth() - ACCENT_WIDTH) / 2, g.getHeight() / 2 + BORDER_SIZE + yOffset);
|
||||||
|
g.drawString(second, (g.getWidth() - ACCENT_WIDTH) / 2, g.getHeight() / 2 + BORDER_SIZE + 20 + yOffset);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the contents area of the default layout
|
||||||
|
*/
|
||||||
|
const clearContent = function () {
|
||||||
|
g.setColor(BG_COLOR);
|
||||||
|
g.fillRect(0, 0, g.getWidth() - ACCENT_WIDTH - PIN_SIZE, g.getHeight());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw the sun figure (above centre, in content area)
|
||||||
|
* @param {Number} progress Progress through the sun expansion animation, between 0 and 1
|
||||||
|
* @param {Number} yOffset Vertical pixel offset of the draw
|
||||||
|
*/
|
||||||
|
const drawSun = function (progress, yOffset) {
|
||||||
|
const p = ease(progress);
|
||||||
|
const sunColor = progress == 1 ? SUN_COLOR_END : g.blendColor(SUN_COLOR_START, SUN_COLOR_END, p);
|
||||||
|
g.setColor(sunColor);
|
||||||
|
g.fillPoly(g.transformVertices(interpolatePoly(BREATHING_POLYS, p), { y: yOffset }));
|
||||||
|
|
||||||
|
if (progress > 0.6) {
|
||||||
|
const faceP = ease((progress - 0.6) * 2.5);
|
||||||
|
g.setColor(g.blendColor(sunColor, SUN_FACE, faceP));
|
||||||
|
g.fillPoly(g.transformVertices(SUN_EYE_LEFT_POLY, { y: map(20, 0, faceP) + yOffset }));
|
||||||
|
g.fillPoly(g.transformVertices(SUN_EYE_LEFT_POLY, { x: SUN_EYE_RIGHT_OFFSET, y: map(20, 0, faceP) + yOffset }));
|
||||||
|
g.fillPoly(g.transformVertices(MOUTH_POLY, { y: map(10, 0, faceP) + yOffset }));
|
||||||
|
}
|
||||||
|
|
||||||
|
g.setColor(TEXT_COLOR);
|
||||||
|
g.fillRect({
|
||||||
|
x: map((g.getWidth() - ACCENT_WIDTH) / 2 - MARKER_SIZE, 20, p),
|
||||||
|
y: map(g.getHeight() / 2 - MARKER_SIZE, g.getHeight() / 2 - MARKER_SIZE / 2, p) + yOffset,
|
||||||
|
x2: map((g.getWidth() - ACCENT_WIDTH) / 2 + MARKER_SIZE, (g.getWidth() - ACCENT_WIDTH) - 20, p),
|
||||||
|
y2: map(g.getHeight() / 2 + MARKER_SIZE / 2, g.getHeight() / 2, p) + yOffset
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Animation functions */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animate clearing the screen to accent color with a single dot in the middle
|
||||||
|
*/
|
||||||
|
const animClearScreen = function () {
|
||||||
|
let oldPoly1 = CLEAR_POLYS_1[0];
|
||||||
|
let oldPoly2 = CLEAR_POLYS_2[0];
|
||||||
|
doAnim(i => {
|
||||||
|
i = ease(i);
|
||||||
|
poly1 = interpolatePoly(CLEAR_POLYS_1, i);
|
||||||
|
poly2 = interpolatePoly(CLEAR_POLYS_2, i);
|
||||||
|
// Fill in black line
|
||||||
|
g.setColor(TEXT_COLOR);
|
||||||
|
g.fillPoly(poly1);
|
||||||
|
g.fillPoly(poly2);
|
||||||
|
|
||||||
|
// Fill in outer shape
|
||||||
|
g.setColor(ACCENT_COLOR);
|
||||||
|
g.fillPoly(oldPoly1);
|
||||||
|
g.fillPoly(oldPoly2);
|
||||||
|
g.flip();
|
||||||
|
|
||||||
|
// Save poly for next loop outer shape
|
||||||
|
oldPoly1 = poly1;
|
||||||
|
oldPoly2 = poly2;
|
||||||
|
}, TIME_CLEAR_ANIM);
|
||||||
|
|
||||||
|
// Draw circle
|
||||||
|
g.setColor(TEXT_COLOR);
|
||||||
|
g.fillCircle(g.getWidth() / 2, g.getHeight() / 2, MARKER_SIZE);
|
||||||
|
g.flip();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animate from a cleared screen and dot to the default layout
|
||||||
|
*/
|
||||||
|
const animDefaultScreen = function () {
|
||||||
|
doAnim(i => {
|
||||||
|
// Draw the circle moving into the corner
|
||||||
|
i = ease(i);
|
||||||
|
const circleX = map(g.getWidth() / 2, MARKER_POS_UPPER[0], i);
|
||||||
|
const circleY = map(g.getHeight() / 2, MARKER_POS_UPPER[1], i);
|
||||||
|
g.setColor(TEXT_COLOR);
|
||||||
|
g.fillCircle(circleX, circleY, MARKER_SIZE);
|
||||||
|
|
||||||
|
// Move the background poly in from the left
|
||||||
|
g.setColor(BG_COLOR);
|
||||||
|
const accentX = map(0, g.getWidth() - ACCENT_WIDTH, i);
|
||||||
|
g.fillPoly([0, 0, accentX, 0, accentX, MARKER_POS_UPPER[1] - PIN_SIZE, accentX - PIN_SIZE, MARKER_POS_UPPER[1], accentX, MARKER_POS_UPPER[1] + PIN_SIZE, accentX, 176, 0, 176]);
|
||||||
|
g.flip();
|
||||||
|
|
||||||
|
// Clear the circle for the next loop
|
||||||
|
g.setColor(ACCENT_COLOR);
|
||||||
|
g.fillCircle(circleX, circleY, MARKER_SIZE + 2);
|
||||||
|
}, TIME_DEFAULT_ANIM);
|
||||||
|
|
||||||
|
// Finish up the circle
|
||||||
|
const w = weather.get();
|
||||||
|
if (w && (w.code || w.txt)) {
|
||||||
|
doAnim(i => {
|
||||||
|
weather.drawIcon(w, MARKER_POS_UPPER[0], MARKER_POS_UPPER[1], MARKER_SIZE * 2);
|
||||||
|
g.setColor(TEXT_COLOR);
|
||||||
|
g.fillCircle(MARKER_POS_UPPER[0], MARKER_POS_UPPER[1], MARKER_SIZE * ease(1 - i));
|
||||||
|
g.flip();
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
g.setColor(TEXT_COLOR);
|
||||||
|
g.fillCircle(MARKER_POS_UPPER[0], MARKER_POS_UPPER[1], MARKER_SIZE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animate the sun figure expand or shrink fully
|
||||||
|
* @param {Number} direction Direction in which to animate. +1 = Expand. -1 = Shrink
|
||||||
|
*/
|
||||||
|
const animSun = function (direction) {
|
||||||
|
doAnim(i => {
|
||||||
|
// Clear and redraw just the sun area
|
||||||
|
g.setColor(BG_COLOR);
|
||||||
|
g.fillRect(0, 31, g.getWidth() - ACCENT_WIDTH - PIN_SIZE, g.getHeight() / 2 + 4);
|
||||||
|
drawSun((direction == 1 ? 0 : 1) + i * direction, 0);
|
||||||
|
g.flip();
|
||||||
|
}, TIME_EVENT_BREAK_ANIM);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animate from centre dot to an event or backwards. Used for entering (forwards) or leaving (backwards) the day-change animation
|
||||||
|
* @param {Number} index Index of the event to draw animate in or out
|
||||||
|
* @param {Number} direction Direction of the animation. +1 = Event -> Dot. -1 = Dot -> Event
|
||||||
|
*/
|
||||||
|
const animEventToMarker = function (index, direction) {
|
||||||
|
doAnim(i => {
|
||||||
|
let ei = direction == 1 ? ease(i) : ease(1 - i);
|
||||||
|
clearContent();
|
||||||
|
drawEvent(index, -(SECONDARY_OFFSET - PRIMARY_OFFSET) * ei, false);
|
||||||
|
g.fillCircle((g.getWidth() - ACCENT_WIDTH) / 2, map(g.getHeight() - MARKER_SIZE - WIDGET_SIZE - BORDER_SIZE, g.getHeight() / 2, ei), MARKER_SIZE);
|
||||||
|
g.flip();
|
||||||
|
}, TIME_EVENT_BREAK_IN);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blit the current contents of content area out of screen, replacing it with something. Currently only for moving stuff upwards.
|
||||||
|
* @param {Function} thing Callback for the new thing to draw on the screen
|
||||||
|
* @param {Number} time How long the animation should last
|
||||||
|
*/
|
||||||
|
const animBlitToX = function (thing, time) {
|
||||||
|
let oldI = 0;
|
||||||
|
doAnim(i => {
|
||||||
|
// Move stuff out of frame, index into frame
|
||||||
|
g.blit({
|
||||||
|
x1: 0,
|
||||||
|
y1: 0,
|
||||||
|
w: g.getWidth() - ACCENT_WIDTH - PIN_SIZE,
|
||||||
|
h: ease(1 - oldI) * g.getHeight(),
|
||||||
|
x2: 0,
|
||||||
|
y2: - (ease(i) - ease(oldI)) * g.getHeight(),
|
||||||
|
setModified: true
|
||||||
|
});
|
||||||
|
g.setColor(BG_COLOR);
|
||||||
|
// Only clear where old stuff no longer is
|
||||||
|
g.fillRect(0, g.getHeight() * (1 - ease(i)), g.getWidth() - ACCENT_WIDTH - PIN_SIZE, g.getHeight());
|
||||||
|
thing(i);
|
||||||
|
g.flip();
|
||||||
|
oldI = i;
|
||||||
|
}, time);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition between one event and another, showing a day-change animation if needed
|
||||||
|
* @param {Number} startIndex The event index that we are animating out of
|
||||||
|
* @param {Number} endIndex The event index that we are animating into
|
||||||
|
*/
|
||||||
|
const animEventTransition = function (startIndex, endIndex) {
|
||||||
|
if (events[startIndex].date == events[endIndex].date) {
|
||||||
|
// If both events are within the same day, just scroll from one to the other.
|
||||||
|
// First determine which event is on top and which direction we are animating in
|
||||||
|
let topIndex = (startIndex < endIndex) ? startIndex : endIndex;
|
||||||
|
let botIndex = (startIndex < endIndex) ? endIndex : startIndex;
|
||||||
|
let direction = (startIndex < endIndex) ? 1 : -1;
|
||||||
|
let offset = (startIndex < endIndex) ? 0 : 1;
|
||||||
|
|
||||||
|
doAnim(i => {
|
||||||
|
// Animate the two events moving towards their destinations
|
||||||
|
clearContent();
|
||||||
|
drawEvent(topIndex, -(SECONDARY_OFFSET - PRIMARY_OFFSET) * ease(offset + direction * i), false);
|
||||||
|
drawEvent(botIndex, (SECONDARY_OFFSET - PRIMARY_OFFSET) - (SECONDARY_OFFSET - PRIMARY_OFFSET) * ease(offset + direction * i), true);
|
||||||
|
g.flip();
|
||||||
|
}, TIME_EVENT_CHANGE);
|
||||||
|
|
||||||
|
// Finally, reset contents and redraw for good measure
|
||||||
|
clearContent();
|
||||||
|
drawEvent(endIndex, 0, true);
|
||||||
|
g.flip();
|
||||||
|
} else {
|
||||||
|
// The events are on different days, trigger day-change animation
|
||||||
|
if (startIndex < endIndex) {
|
||||||
|
// Destination is later, Stuff moves upwards
|
||||||
|
animEventToMarker(startIndex, 1); // The day-end dot moves to center of screen
|
||||||
|
drawFigureCaption(events[endIndex].weekday, events[endIndex].date, 0); // Caption between sun appears, no need to continuously redraw
|
||||||
|
animSun(1); // Animate the sun expanding
|
||||||
|
doAnim(i => { }, TIME_EVENT_BREAK_HALT); // Wait for a moment
|
||||||
|
animBlitToX(i => { drawEvent(endIndex, g.getHeight() - g.getHeight() * ease(i), true); }, TIME_EVENT_BREAK_OUT); // Blit the sun and caption out, replacing with destination event
|
||||||
|
} else {
|
||||||
|
// Destination is earlier, content moves downwards
|
||||||
|
doAnim(i => {
|
||||||
|
// Can't animBlit, draw sun and figure caption replacing origin event
|
||||||
|
clearContent();
|
||||||
|
drawEvent(startIndex, g.getHeight() * ease(i), true);
|
||||||
|
drawSun(1, - g.getHeight() * ease(1 - i));
|
||||||
|
drawFigureCaption(events[endIndex].weekday, events[endIndex].date, - g.getHeight() * ease(1 - i));
|
||||||
|
g.flip();
|
||||||
|
}, TIME_EVENT_BREAK_OUT);
|
||||||
|
doAnim(i => { }, TIME_EVENT_BREAK_HALT); // Wait for a moment
|
||||||
|
animSun(-1); // Collapse the sun
|
||||||
|
animEventToMarker(endIndex, -1); // Animate from dot to destination event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.flip();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bump the event because we've reached an end
|
||||||
|
* @param {Number} index The index of the event which we are currently at (probably last)
|
||||||
|
* @param {Number} direction Which direction to bump. +1 = content moves down, then up. -1 = content moves up, back down
|
||||||
|
*/
|
||||||
|
const animEventBump = function (index, direction) {
|
||||||
|
doAnim(i => {
|
||||||
|
clearContent();
|
||||||
|
drawEvent(index, Math.sin(Math.PI * i) * 24 * direction, true);
|
||||||
|
g.flip();
|
||||||
|
}, TIME_BUMP_ANIM);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the exit animation of the application
|
||||||
|
*/
|
||||||
|
const animExit = function () {
|
||||||
|
// First, move out (downwards) the current event
|
||||||
|
doAnim(i => {
|
||||||
|
clearContent();
|
||||||
|
drawEvent(currentEventIndex, ease(i) * g.getHeight(), true);
|
||||||
|
g.flip();
|
||||||
|
}, TIME_EXIT_ANIM / 3 * 2);
|
||||||
|
|
||||||
|
// Clear the screen leftwards with the accent color
|
||||||
|
g.setColor(ACCENT_COLOR);
|
||||||
|
doAnim(i => {
|
||||||
|
g.fillRect(ease(1 - i) * g.getWidth(), 0, g.getWidth(), g.getHeight());
|
||||||
|
g.flip();
|
||||||
|
}, TIME_EXIT_ANIM / 3);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animate from empty default screen to the first event to show.
|
||||||
|
* If the event we're moving to is not later today, show the date first.
|
||||||
|
*/
|
||||||
|
const animFirstEvent = function () {
|
||||||
|
if (!isSameDay(new Date(events[currentEventIndex].timestamp * 1000), new Date())) {
|
||||||
|
drawFigureCaption(events[currentEventIndex].weekday, events[currentEventIndex].date, 0);
|
||||||
|
animSun(1);
|
||||||
|
doAnim(i => { }, TIME_EVENT_BREAK_HALT);
|
||||||
|
animBlitToX(i => { drawEvent(currentEventIndex, g.getHeight() - g.getHeight() * ease(i), true); }, TIME_EVENT_BREAK_OUT, 1);
|
||||||
|
} else {
|
||||||
|
drawEvent(currentEventIndex, 0, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Setup */
|
||||||
|
|
||||||
|
/* Load events */
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date();
|
||||||
|
const yesterday = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
g.setFont("6x12");
|
||||||
|
const locale = require("locale");
|
||||||
|
|
||||||
|
let events = (require("Storage").readJSON("android.calendar.json", true) || []).map(event => {
|
||||||
|
// Title uses 8x16 font, 8 px wide characters. Limit title to fit on a line.
|
||||||
|
let title = event.title;
|
||||||
|
if (title.length > (g.getWidth() - 2 * BORDER_SIZE - ACCENT_WIDTH) / 8) {
|
||||||
|
title = title.slice(0, ((g.getWidth() - 2 * BORDER_SIZE - ACCENT_WIDTH) / 8) - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap description to fit four lines of content
|
||||||
|
let description = g.wrapString(event.description, g.getWidth() - 2 * BORDER_SIZE - ACCENT_WIDTH - PIN_SIZE).slice(0, 4).join("\n");
|
||||||
|
|
||||||
|
// Set weekday text
|
||||||
|
let eventDate = new Date(event.timestamp * 1000);
|
||||||
|
let weekday = locale.dow(eventDate);
|
||||||
|
if (isSameDay(eventDate, today)) {
|
||||||
|
weekday = /*LANG*/"Today";
|
||||||
|
} else if (isSameDay(eventDate, tomorrow)) {
|
||||||
|
weekday = /*LANG*/"Tomorrow";
|
||||||
|
} else if (isSameDay(eventDate, yesterday)) {
|
||||||
|
weekday = /*LANG*/"Yesterday";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
weekday: weekday,
|
||||||
|
date: locale.date(eventDate, 1),
|
||||||
|
time: locale.time(eventDate, 1) + locale.meridian(eventDate),
|
||||||
|
title: title,
|
||||||
|
description: description
|
||||||
|
};
|
||||||
|
}).sort((a, b) => { return a.timestamp - b.timestamp; });
|
||||||
|
|
||||||
|
// If no events, add a note.
|
||||||
|
if (events.length == 0) {
|
||||||
|
events[0] = {
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
weekday: /*LANG*/"Today",
|
||||||
|
date: require("locale").date(new Date(), 1),
|
||||||
|
time: require("locale").time(new Date(), 1),
|
||||||
|
title: /*LANG*/"No events",
|
||||||
|
description: /*LANG*/"Nothing to do"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should start at the first event later than now
|
||||||
|
let currentEventIndex = events.findIndex((event) => { return event.timestamp * 1000 > Date.now(); });
|
||||||
|
if (currentEventIndex == -1) currentEventIndex = 0; // Or just first event if none found
|
||||||
|
|
||||||
|
// Setup the UI with remove to support fast load
|
||||||
|
Bangle.setUI({
|
||||||
|
mode: "custom",
|
||||||
|
btn: () => { animExit(); Bangle.load(); },
|
||||||
|
remove: function () {
|
||||||
|
require("widget_utils").show();
|
||||||
|
delete Graphics.prototype.Font6x12;
|
||||||
|
delete Graphics.prototype.Font8x16;
|
||||||
|
Bangle.removeListener('swipe', onSwipe);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for swipe gesture. Transitions between adjacent events.
|
||||||
|
* @param {Number} directionLR Unused.
|
||||||
|
* @param {Number} directionUD Whether swipe direction is up or down
|
||||||
|
*/
|
||||||
|
const onSwipe = function (directionLR, directionUD) {
|
||||||
|
if (directionUD == -1) {
|
||||||
|
// Swiping up
|
||||||
|
if (currentEventIndex + 1 < events.length) {
|
||||||
|
// Animate to the next event
|
||||||
|
animEventTransition(currentEventIndex, currentEventIndex + 1);
|
||||||
|
currentEventIndex += 1;
|
||||||
|
} else {
|
||||||
|
// We've hit the end, bump
|
||||||
|
animEventBump(currentEventIndex, -1);
|
||||||
|
}
|
||||||
|
} else if (directionUD == 1) {
|
||||||
|
//Swiping down
|
||||||
|
if (currentEventIndex > 0) {
|
||||||
|
// Animate to the previous event
|
||||||
|
animEventTransition(currentEventIndex, currentEventIndex - 1);
|
||||||
|
currentEventIndex -= 1;
|
||||||
|
} else {
|
||||||
|
// If swiping earlier than earliest event, exit back to watchface
|
||||||
|
animExit();
|
||||||
|
Bangle.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ready animations for showing the first event, then register swipe listener for switching events
|
||||||
|
setTimeout(() => {
|
||||||
|
animDefaultScreen();
|
||||||
|
animFirstEvent();
|
||||||
|
Bangle.on('swipe', onSwipe);
|
||||||
|
}, TIME_CLEAR_ANIM + TIME_CLEAR_BREAK);
|
||||||
|
animClearScreen(); // Start visible changes by clearing the screen
|
||||||
|
|
||||||
|
// Load and hide widgets to background
|
||||||
|
Bangle.loadWidgets();
|
||||||
|
require("widget_utils").hide();
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 479 B |
|
@ -0,0 +1,26 @@
|
||||||
|
{ "id": "rebbleagenda",
|
||||||
|
"name": "Rebble Agenda",
|
||||||
|
"shortName":"Agenda",
|
||||||
|
"version":"0.01",
|
||||||
|
"description": "A pebble-inspired animated agenda",
|
||||||
|
"icon": "app.png",
|
||||||
|
"screenshots" : [
|
||||||
|
{ "url":"screenshot_rebbleagenda_events.png" },
|
||||||
|
{ "url":"screenshot_rebbleagenda_customtheme.png" },
|
||||||
|
{ "url":"screenshot_rebbleagenda_sun.png" }
|
||||||
|
],
|
||||||
|
"type": "app",
|
||||||
|
"tags": "agenda,tool",
|
||||||
|
"supports" : ["BANGLEJS2"],
|
||||||
|
"readme": "README.md",
|
||||||
|
"allow_emulator": true,
|
||||||
|
"dependencies" : { "weather":"app" },
|
||||||
|
"storage": [
|
||||||
|
{"name":"rebbleagenda.app.js","url":"app.js"},
|
||||||
|
{"name":"rebbleagenda.settings.js","url":"settings.js"},
|
||||||
|
{"name":"rebbleagenda.img","url":"app-icon.js","evaluate":true}
|
||||||
|
],
|
||||||
|
"data": [
|
||||||
|
{"name":"rebbleagenda.json"}
|
||||||
|
]
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
After Width: | Height: | Size: 2.4 KiB |
|
@ -0,0 +1,69 @@
|
||||||
|
(function (back) {
|
||||||
|
const SETTINGS_FILE = "rebbleagenda.json";
|
||||||
|
|
||||||
|
// initialize with default settings...
|
||||||
|
let s = {
|
||||||
|
'system': true,
|
||||||
|
'bg': "#FFF",
|
||||||
|
'fg': "#000",
|
||||||
|
'acc': "#0FF"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ...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 = storage.readJSON(SETTINGS_FILE, 1) || {};
|
||||||
|
const saved = settings || {};
|
||||||
|
for (const key in saved) {
|
||||||
|
s[key] = saved[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = function () {
|
||||||
|
settings = s;
|
||||||
|
storage.write(SETTINGS_FILE, settings);
|
||||||
|
};
|
||||||
|
|
||||||
|
const color_options = [/*LANG*/"Red", /*LANG*/"Green", /*LANG*/"Blue", /*LANG*/"Purple", /*LANG*/"Cyan", /*LANG*/"Orange", /*LANG*/"Grey"];
|
||||||
|
const color_codes = ['#F00','#0F0','#00F','#F0F','#0FF','#FF0', "#888"];
|
||||||
|
const ground_options = [/*LANG*/"Black", /*LANG*/"White", /*LANG*/"Dark Blue", /*LANG*/"Dark Red", /*LANG*/"Dark Green", /*LANG*/"Light Blue", /*LANG*/"Light Red", /*LANG*/"Light Green"];
|
||||||
|
const ground_codes = ["#000", "#FFF", "#003", "#300", "#030", "#BBF", "#FBB", "#BFB"];
|
||||||
|
|
||||||
|
E.showMenu({
|
||||||
|
'': { 'title': 'Rebble Agenda' },
|
||||||
|
['< '+/*LANG*/'Back']: back,
|
||||||
|
/*LANG*/'Use system theme': {
|
||||||
|
value: !!s.system,
|
||||||
|
onchange: v => {
|
||||||
|
s.system = v;
|
||||||
|
save();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/*LANG*/'Accent': {
|
||||||
|
value: 0 | color_codes.indexOf(s.acc),
|
||||||
|
min: 0, max: color_codes.length-1,
|
||||||
|
format: v => color_options[v],
|
||||||
|
onchange: v => {
|
||||||
|
s.acc = color_codes[v];
|
||||||
|
save();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/*LANG*/'Background': {
|
||||||
|
value: 0 | ground_codes.indexOf(s.bg),
|
||||||
|
min: 0, max: ground_codes.length-1,
|
||||||
|
format: v => ground_options[v],
|
||||||
|
onchange: v => {
|
||||||
|
s.bg = ground_codes[v];
|
||||||
|
save();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/*LANG*/'Foreground': {
|
||||||
|
value: 0 | ground_codes.indexOf(s.fg),
|
||||||
|
min: 0, max: ground_codes.length-1,
|
||||||
|
format: v => ground_options[v],
|
||||||
|
onchange: v => {
|
||||||
|
s.fg = ground_codes[v];
|
||||||
|
save();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue