Compare commits

...

2 Commits

Author SHA1 Message Date
bgrolleman 2d242a48e1 Validate config values before storing and sending to watch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:29:26 +02:00
bgrolleman d5b9c443a0 Remove calendar appointment bar integration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:14:14 +02:00
3 changed files with 181 additions and 128 deletions
+3 -1
View File
@@ -23,7 +23,9 @@
"watchface": true
},
"messageKeys": [
"APPOINTMENT_MINUTES"
"BAR_CHUNK_MINS",
"DAY_START_HOUR",
"DAY_END_HOUR"
],
"resources": {
"media": [
+95 -74
View File
@@ -1,64 +1,53 @@
#include <pebble.h>
#define PERSIST_KEY_DAY_START 1
#define PERSIST_KEY_DAY_END 2
#define PERSIST_KEY_CHUNK_MINS 3
static Window *s_main_window;
static TextLayer *s_time_layer;
static TextLayer *s_date_layer;
static Layer *s_bar_layer;
static Layer *s_day_bar_layer;
static Layer *s_battery_layer;
static Layer *s_calendar_layer;
static GFont s_date_font;
static bool s_tracking_appt = false;
static const char * const MONTHS[] = {
"JAN","FEB","MAR","APR","MAY","JUN",
"JUL","AUG","SEP","OCT","NOV","DEC"
};
static int s_battery_level = 100;
static int s_bar_fill = 30; // 030: minutes remaining
static int s_appt_mins = -1; // minutes to next appointment, -1 = none within 30 min
static int s_bar_fill = 30;
static int s_day_bar_fill = 16;
static int mins_to_next_half(void) {
static int s_day_start_hour = 6;
static int s_day_end_hour = 22;
static int s_bar_chunk_mins = 30;
static int mins_to_next_chunk(void) {
time_t now = time(NULL);
struct tm *t = localtime(&now);
return 30 - (t->tm_min % 30); // returns 130
return s_bar_chunk_mins - (t->tm_min % s_bar_chunk_mins);
}
static void update_bar(void) {
int half_mins = mins_to_next_half();
int target = half_mins;
s_tracking_appt = (s_appt_mins >= 0 && s_appt_mins < half_mins);
if (s_tracking_appt) {
target = s_appt_mins;
}
s_bar_fill = target;
if (s_bar_fill > 30) s_bar_fill = 30;
if (s_bar_fill < 0) s_bar_fill = 0;
layer_mark_dirty(s_bar_layer);
layer_mark_dirty(s_calendar_layer);
}
static void bar_draw(Layer *layer, GContext *ctx) {
GRect bounds = layer_get_bounds(layer);
static void draw_progress_bar(GContext *ctx, GRect bounds, int num_blocks, int fill) {
graphics_context_set_fill_color(ctx, GColorBlack);
graphics_fill_rect(ctx, bounds, 0, GCornerNone);
// Fit 30 blocks with 1px gaps; centre the result
int block_w = (bounds.size.w - 29) / 30;
int total_w = 30 * block_w + 29;
if (num_blocks <= 0) return;
int gap = 1;
int block_w = (bounds.size.w - (num_blocks - 1) * gap) / num_blocks;
if (block_w < 1) block_w = 1;
int total_w = num_blocks * block_w + (num_blocks - 1) * gap;
int start_x = (bounds.size.w - total_w) / 2;
for (int i = 0; i < 30; i++) {
int x = start_x + i * (block_w + 1);
GRect block = GRect(x, 0, block_w, bounds.size.h);
if (i >= 30 - s_bar_fill) {
for (int i = 0; i < num_blocks; i++) {
int x = start_x + i * (block_w + gap);
if (i >= num_blocks - fill) {
graphics_context_set_fill_color(ctx, GColorWhite);
graphics_fill_rect(ctx, block, 0, GCornerNone);
graphics_fill_rect(ctx, GRect(x, 0, block_w, bounds.size.h), 0, GCornerNone);
} else {
graphics_context_set_fill_color(ctx, GColorWhite);
graphics_fill_rect(ctx, GRect(x, bounds.size.h - 1, block_w, 1), 0, GCornerNone);
@@ -66,6 +55,41 @@ static void bar_draw(Layer *layer, GContext *ctx) {
}
}
static void bar_draw(Layer *layer, GContext *ctx) {
draw_progress_bar(ctx, layer_get_bounds(layer), s_bar_chunk_mins, s_bar_fill);
}
static void day_bar_draw(Layer *layer, GContext *ctx) {
draw_progress_bar(ctx, layer_get_bounds(layer),
s_day_end_hour - s_day_start_hour, s_day_bar_fill);
}
static void update_bar(void) {
time_t now = time(NULL);
struct tm *t = localtime(&now);
// Top bar: countdown to next chunk boundary
int chunk_mins = mins_to_next_chunk();
s_bar_fill = chunk_mins < 0 ? 0 : (chunk_mins > s_bar_chunk_mins ? s_bar_chunk_mins : chunk_mins);
// Day bar: whole hours remaining (ceiling) within the configured window
int total_blocks = s_day_end_hour - s_day_start_hour;
int current_mins = t->tm_hour * 60 + t->tm_min;
int mins_left = s_day_end_hour * 60 - current_mins;
if (mins_left <= 0) {
s_day_bar_fill = 0;
} else if (current_mins < s_day_start_hour * 60) {
s_day_bar_fill = total_blocks;
} else {
s_day_bar_fill = (mins_left + 59) / 60;
if (s_day_bar_fill > total_blocks) s_day_bar_fill = total_blocks;
}
layer_mark_dirty(s_bar_layer);
layer_mark_dirty(s_day_bar_layer);
}
static void battery_draw(Layer *layer, GContext *ctx) {
GRect bounds = layer_get_bounds(layer);
graphics_context_set_fill_color(ctx, GColorBlack);
@@ -73,43 +97,38 @@ static void battery_draw(Layer *layer, GContext *ctx) {
if (s_battery_level >= 20) return;
// Body outline
graphics_context_set_stroke_color(ctx, GColorWhite);
graphics_draw_rect(ctx, GRect(0, 1, 19, 10));
// Terminal nub
graphics_context_set_fill_color(ctx, GColorWhite);
graphics_fill_rect(ctx, GRect(19, 4, 3, 4), 0, GCornerNone);
// Charge fill
int fill_w = (s_battery_level * 15) / 100;
if (fill_w > 0) {
graphics_fill_rect(ctx, GRect(2, 3, fill_w, 6), 0, GCornerNone);
}
}
static void calendar_draw(Layer *layer, GContext *ctx) {
GRect bounds = layer_get_bounds(layer);
graphics_context_set_fill_color(ctx, GColorBlack);
graphics_fill_rect(ctx, bounds, 0, GCornerNone);
if (!s_tracking_appt) return;
// Outline
graphics_context_set_stroke_color(ctx, GColorWhite);
graphics_draw_rect(ctx, GRect(0, 0, 12, 12));
// Header bar
graphics_context_set_fill_color(ctx, GColorWhite);
graphics_fill_rect(ctx, GRect(1, 1, 10, 3), 0, GCornerNone);
// Two date squares
graphics_fill_rect(ctx, GRect(2, 6, 3, 3), 0, GCornerNone);
graphics_fill_rect(ctx, GRect(7, 6, 3, 3), 0, GCornerNone);
}
static void inbox_received(DictionaryIterator *iter, void *context) {
Tuple *t = dict_find(iter, MESSAGE_KEY_APPOINTMENT_MINUTES);
Tuple *t;
t = dict_find(iter, MESSAGE_KEY_DAY_START_HOUR);
if (t) {
s_appt_mins = (int)t->value->int32;
update_bar();
s_day_start_hour = (int)t->value->int32;
persist_write_int(PERSIST_KEY_DAY_START, s_day_start_hour);
}
t = dict_find(iter, MESSAGE_KEY_DAY_END_HOUR);
if (t) {
s_day_end_hour = (int)t->value->int32;
persist_write_int(PERSIST_KEY_DAY_END, s_day_end_hour);
}
t = dict_find(iter, MESSAGE_KEY_BAR_CHUNK_MINS);
if (t) {
s_bar_chunk_mins = (int)t->value->int32;
persist_write_int(PERSIST_KEY_CHUNK_MINS, s_bar_chunk_mins);
}
update_bar();
}
static void update_display(struct tm *tick_time) {
@@ -137,13 +156,11 @@ static void main_window_load(Window *window) {
Layer *root = window_get_root_layer(window);
GRect bounds = layer_get_bounds(root);
// Shared horizontal geometry matching the bar's block layout
// Text area aligned to 30-block bar width (middle setting)
int block_w = (bounds.size.w - 29) / 30;
int bar_w = 30 * block_w + 29;
int bar_x = (bounds.size.w - bar_w) / 2;
// Time — right-aligned to bar, almost touching bar
// Leco 60 is emery-exclusive; fall back to Leco 42 on other platforms
#ifdef PBL_PLATFORM_EMERY
s_time_layer = text_layer_create(GRect(bar_x, 18, bar_w, 68));
text_layer_set_font(s_time_layer, fonts_get_system_font(FONT_KEY_LECO_60_BOLD_NUMBERS_AM_PM));
@@ -157,14 +174,19 @@ static void main_window_load(Window *window) {
text_layer_set_text(s_time_layer, "00:00");
layer_add_child(root, text_layer_get_layer(s_time_layer));
// Countdown bar — 30 blocks, right-to-left
s_bar_layer = layer_create(GRect(0, 90, bounds.size.w, 14));
// Chunk countdown bar
s_bar_layer = layer_create(GRect(0, 90, bounds.size.w, 10));
layer_set_update_proc(s_bar_layer, bar_draw);
layer_add_child(root, s_bar_layer);
// Date — Roboto Bold 36 custom font, right-aligned to bar
// Day progress bar
s_day_bar_layer = layer_create(GRect(0, 102, bounds.size.w, 10));
layer_set_update_proc(s_day_bar_layer, day_bar_draw);
layer_add_child(root, s_day_bar_layer);
// Date
s_date_font = fonts_load_custom_font(resource_get_handle(RESOURCE_ID_FONT_ROBOTO_BOLD_36));
s_date_layer = text_layer_create(GRect(bar_x, 106, bar_w, 42));
s_date_layer = text_layer_create(GRect(bar_x, 114, bar_w, 40));
text_layer_set_background_color(s_date_layer, GColorClear);
text_layer_set_text_color(s_date_layer, GColorWhite);
text_layer_set_font(s_date_layer, s_date_font);
@@ -172,13 +194,8 @@ static void main_window_load(Window *window) {
text_layer_set_text(s_date_layer, "1 JAN");
layer_add_child(root, text_layer_get_layer(s_date_layer));
// Calendar icon — bottom right, visible when counting down to an appointment
s_calendar_layer = layer_create(GRect(bounds.size.w - 42, bounds.size.h - 16, 12, 12));
layer_set_update_proc(s_calendar_layer, calendar_draw);
layer_add_child(root, s_calendar_layer);
// Battery icon — bottom right, only visible below 20%
s_battery_layer = layer_create(GRect(bounds.size.w - 26, bounds.size.h - 16, 23, 12));
s_battery_layer = layer_create(GRect(bounds.size.w - 26, bounds.size.h - 14, 23, 12));
layer_set_update_proc(s_battery_layer, battery_draw);
layer_add_child(root, s_battery_layer);
}
@@ -187,12 +204,16 @@ static void main_window_unload(Window *window) {
text_layer_destroy(s_time_layer);
text_layer_destroy(s_date_layer);
layer_destroy(s_bar_layer);
layer_destroy(s_calendar_layer);
layer_destroy(s_day_bar_layer);
layer_destroy(s_battery_layer);
fonts_unload_custom_font(s_date_font);
}
static void init(void) {
s_day_start_hour = persist_exists(PERSIST_KEY_DAY_START) ? persist_read_int(PERSIST_KEY_DAY_START) : 6;
s_day_end_hour = persist_exists(PERSIST_KEY_DAY_END) ? persist_read_int(PERSIST_KEY_DAY_END) : 22;
s_bar_chunk_mins = persist_exists(PERSIST_KEY_CHUNK_MINS) ? persist_read_int(PERSIST_KEY_CHUNK_MINS) : 30;
s_main_window = window_create();
window_set_background_color(s_main_window, GColorBlack);
@@ -207,7 +228,7 @@ static void init(void) {
battery_callback(battery_state_service_peek());
app_message_register_inbox_received(inbox_received);
app_message_open(64, 0);
app_message_open(128, 0);
time_t now = time(NULL);
update_display(localtime(&now));
+83 -53
View File
@@ -1,64 +1,94 @@
// Calendar integration via Google Calendar API.
// To enable: store your API key with:
// localStorage.setItem('gcal_api_key', 'YOUR_API_KEY');
// A public/service-account read-only key for your calendar is sufficient.
// Without it, the bar only counts down to the next :00 or :30.
// --- Configuration ---
var CALENDAR_ID = 'primary';
function getStoredConfig() {
return {
chunk: parseInt(localStorage.getItem('chunk') || '30', 10),
start: parseInt(localStorage.getItem('start') || '6', 10),
end: parseInt(localStorage.getItem('end') || '22', 10)
};
}
function sendAppointmentMinutes(minutes) {
function sendConfig(cfg) {
Pebble.sendAppMessage(
{ 'APPOINTMENT_MINUTES': minutes },
{
'BAR_CHUNK_MINS': cfg.chunk,
'DAY_START_HOUR': cfg.start,
'DAY_END_HOUR': cfg.end
},
function() {},
function(e) { console.log('AppMessage send failed: ' + JSON.stringify(e)); }
function(e) { console.log('Config send failed: ' + JSON.stringify(e)); }
);
}
function checkCalendar() {
var apiKey = localStorage.getItem('gcal_api_key');
if (!apiKey) {
sendAppointmentMinutes(-1);
return;
}
var now = new Date();
var horizon = new Date(now.getTime() + 30 * 60 * 1000);
var url = 'https://www.googleapis.com/calendar/v3/calendars/' +
encodeURIComponent(CALENDAR_ID) + '/events' +
'?key=' + apiKey +
'&timeMin=' + now.toISOString() +
'&timeMax=' + horizon.toISOString() +
'&singleEvents=true&orderBy=startTime&maxResults=1';
var xhr = new XMLHttpRequest();
xhr.onload = function() {
try {
if (xhr.status !== 200) {
sendAppointmentMinutes(-1);
return;
}
var data = JSON.parse(xhr.responseText);
if (data.items && data.items.length > 0) {
var item = data.items[0];
var start = new Date(item.start.dateTime || item.start.date);
var mins = Math.round((start - now) / 60000);
sendAppointmentMinutes(Math.max(0, Math.min(30, mins)));
} else {
sendAppointmentMinutes(-1);
}
} catch (e) {
sendAppointmentMinutes(-1);
}
};
xhr.onerror = function() {
sendAppointmentMinutes(-1);
};
xhr.open('GET', url);
xhr.send();
function buildConfigPage(chunk, start, end) {
return '<!DOCTYPE html>' +
'<html><head>' +
'<meta charset="utf-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
'<title>Watchface Settings</title>' +
'<style>' +
'body{background:#111;color:#eee;font-family:-apple-system,sans-serif;padding:20px;margin:0}' +
'h2{margin:0 0 24px;font-size:20px}' +
'label{display:block;margin-top:20px;font-size:13px;color:#999;text-transform:uppercase;letter-spacing:.05em}' +
'select,input{display:block;width:100%;box-sizing:border-box;background:#222;color:#eee;' +
'border:1px solid #444;border-radius:6px;padding:10px;margin-top:6px;font-size:16px;-webkit-appearance:none}' +
'.btns{display:flex;gap:10px;margin-top:32px}' +
'button{flex:1;padding:14px;border:none;border-radius:6px;font-size:16px;font-weight:600;cursor:pointer}' +
'#sv{background:#0A84FF;color:#fff}' +
'#cn{background:#2a2a2a;color:#eee}' +
'</style></head><body>' +
'<h2>Watchface Settings</h2>' +
'<label>Top bar interval' +
'<select id="q">' +
'<option value="15">15 min — quarter hour</option>' +
'<option value="30">30 min — half hour</option>' +
'<option value="60">60 min — full hour</option>' +
'</select></label>' +
'<label>Day start (hour, 023)<input type="number" id="a" min="0" max="23"></label>' +
'<label>Day end (hour, 023)<input type="number" id="b" min="0" max="23"></label>' +
'<div class="btns"><button id="sv">Save</button><button id="cn">Cancel</button></div>' +
'<script>' +
'document.getElementById("q").value=' + chunk + ';' +
'document.getElementById("a").value=' + start + ';' +
'document.getElementById("b").value=' + end + ';' +
'document.getElementById("sv").addEventListener("click",function(){' +
'var d={chunk:+document.getElementById("q").value,' +
'start:+document.getElementById("a").value,' +
'end:+document.getElementById("b").value};' +
'location.href="pebblekit-js://"+encodeURIComponent(JSON.stringify(d));' +
'});' +
'document.getElementById("cn").addEventListener("click",function(){' +
'location.href="pebblekit-js://cancel";' +
'});' +
'<\/script></body></html>';
}
Pebble.addEventListener('ready', function() {
checkCalendar();
setInterval(checkCalendar, 60 * 1000);
sendConfig(getStoredConfig());
});
Pebble.addEventListener('showConfiguration', function() {
var cfg = getStoredConfig();
var html = buildConfigPage(cfg.chunk, cfg.start, cfg.end);
Pebble.openURL('data:text/html,' + encodeURIComponent(html));
});
Pebble.addEventListener('webviewclosed', function(e) {
if (!e.response || e.response === 'cancel') return;
try {
var cfg = JSON.parse(decodeURIComponent(e.response));
var chunk = parseInt(cfg.chunk, 10);
var start = parseInt(cfg.start, 10);
var end = parseInt(cfg.end, 10);
if (chunk <= 0 || chunk > 60) return;
if (start < 0 || start > 22) return;
if (end < 1 || end > 23) return;
if (start >= end) return;
localStorage.setItem('chunk', chunk);
localStorage.setItem('start', start);
localStorage.setItem('end', end);
sendConfig({ chunk: chunk, start: start, end: end });
} catch (err) {
console.log('Failed to parse config: ' + err);
}
});