From 7d636013556d934b9631e4db99a1cdb11c97df22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sarah=20Fjelsted=20Alr=C3=B8e?= Date: Sun, 3 Sep 2023 21:50:54 +0200 Subject: [PATCH] Added app 'Rebble Agenda' --- apps/rebbleagenda/ChangeLog | 1 + apps/rebbleagenda/README.md | 24 + apps/rebbleagenda/app-icon.js | 1 + apps/rebbleagenda/app.js | 583 ++++++++++++++++++ apps/rebbleagenda/app.png | Bin 0 -> 479 bytes apps/rebbleagenda/metadata.json | 26 + .../screenshot_rebbleagenda_customtheme.png | Bin 0 -> 2878 bytes .../screenshot_rebbleagenda_events.png | Bin 0 -> 2654 bytes .../screenshot_rebbleagenda_sun.png | Bin 0 -> 2430 bytes apps/rebbleagenda/settings.js | 69 +++ 10 files changed, 704 insertions(+) create mode 100644 apps/rebbleagenda/ChangeLog create mode 100644 apps/rebbleagenda/README.md create mode 100644 apps/rebbleagenda/app-icon.js create mode 100644 apps/rebbleagenda/app.js create mode 100644 apps/rebbleagenda/app.png create mode 100644 apps/rebbleagenda/metadata.json create mode 100644 apps/rebbleagenda/screenshot_rebbleagenda_customtheme.png create mode 100644 apps/rebbleagenda/screenshot_rebbleagenda_events.png create mode 100644 apps/rebbleagenda/screenshot_rebbleagenda_sun.png create mode 100644 apps/rebbleagenda/settings.js diff --git a/apps/rebbleagenda/ChangeLog b/apps/rebbleagenda/ChangeLog new file mode 100644 index 0000000000..ec66c5568c --- /dev/null +++ b/apps/rebbleagenda/ChangeLog @@ -0,0 +1 @@ +0.01: Initial version diff --git a/apps/rebbleagenda/README.md b/apps/rebbleagenda/README.md new file mode 100644 index 0000000000..77afd4b486 --- /dev/null +++ b/apps/rebbleagenda/README.md @@ -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. + +![Two events shown using the default light system theme](./screenshot_rebbleagenda_events.png) ![The last event of the agenda shown using a custom red theme](./screenshot_rebbleagenda_customtheme.png) ![An animated sun shows the day of the following events](./screenshot_rebbleagenda_sun.png) + +## 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 diff --git a/apps/rebbleagenda/app-icon.js b/apps/rebbleagenda/app-icon.js new file mode 100644 index 0000000000..2b2773ee7b --- /dev/null +++ b/apps/rebbleagenda/app-icon.js @@ -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")) \ No newline at end of file diff --git a/apps/rebbleagenda/app.js b/apps/rebbleagenda/app.js new file mode 100644 index 0000000000..3b6eca900f --- /dev/null +++ b/apps/rebbleagenda/app.js @@ -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; // �=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(); +} \ No newline at end of file diff --git a/apps/rebbleagenda/app.png b/apps/rebbleagenda/app.png new file mode 100644 index 0000000000000000000000000000000000000000..20715656561dcb8df20a8ebd30c1bba33e7e0335 GIT binary patch literal 479 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 literal 0 HcmV?d00001 diff --git a/apps/rebbleagenda/screenshot_rebbleagenda_events.png b/apps/rebbleagenda/screenshot_rebbleagenda_events.png new file mode 100644 index 0000000000000000000000000000000000000000..c94c0d9c4747b84a0d15d63d77328b5974216b05 GIT binary patch literal 2654 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 { + 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(); + }, + } + }); +}); \ No newline at end of file