Compare commits

...

10 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
bgrolleman 6c239163de Show calendar icon when counting down to an appointment
Adds a small calendar icon (outline + header bar + two date squares) in the
bottom-right corner, to the left of the battery icon. It appears only when
the countdown bar is tracking a calendar event rather than the half-hour cycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 21:02:29 +02:00
bgrolleman 8d509af3f8 Leco 60 time, Roboto date with month name, taller bar with bottom-line empty blocks
- Time: FONT_KEY_LECO_60_BOLD_NUMBERS_AM_PM (emery), Leco 42 fallback
- Date: embedded Roboto Bold 36, format changed to "D MMM" (e.g. 23 MAY)
- Countdown bar: height 8 → 14px; empty blocks show bottom line only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:08:42 +02:00
bgrolleman 0becfa0243 Refine layout and bar direction
- Date font: Leco 38 Bold → Leco 28 Light
- Countdown bar fills right-to-left (time remaining shrinks from right)
- Time and date pulled close to the bar (4px gap each side)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:47:28 +02:00
bgrolleman 6bb04801bc Move countdown bar between time and date, split into 30 minute blocks
Replace the single progress bar with 30 individual blocks — one per minute.
Filled blocks show remaining time, outlined blocks show elapsed. Bar is now
positioned between the time and date layers instead of at the top.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:44:03 +02:00
bgrolleman f3365a8dac Switch to Leco fonts for time and date
Time: FONT_KEY_LECO_42_NUMBERS (largest available Leco)
Date: FONT_KEY_LECO_38_BOLD_NUMBERS, right-aligned

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:36:59 +02:00
bgrolleman ebca07666e Replace battery bar with low-battery icon
Remove the full-width bottom bar. Instead draw a small battery icon in the
bottom-right corner only when charge drops below 20%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:32:31 +02:00
bgrolleman 92dfcfabcd Use Bitham 30 for date, right-aligned
Switch date from Gothic 28 Bold (centred) to Bitham 30 Black (right-aligned)
for a slightly larger, better-positioned date display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:29:36 +02:00
bgrolleman eac496be0e Redesign watchface with countdown bar and calendar support
- Force 24h time format; switch date to DD/MM (Gothic 28 Bold)
- Replace effect-layer invert with explicit black background + white text
- Add 8px top bar counting down to next :00/:30; preempts to nearest
  calendar appointment when one arrives via AppMessage within 30 min
- Add pkjs with Google Calendar API integration (falls back gracefully
  if no API key is configured)
- Move battery indicator to a 4px full-width strip at screen bottom
- Drop pebble-effect-layer dependency; add APPOINTMENT_MINUTES messageKey
- Write README with feature overview, build instructions, and calendar setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 12:39:07 +02:00
5 changed files with 347 additions and 74 deletions
+52
View File
@@ -0,0 +1,52 @@
# Countdown Watchface
A clean Pebble watchface with a top progress bar that counts down to your next half-hour mark or upcoming calendar appointment.
## Features
- **Time** — large 24-hour format (`HH:MM`) using the Roboto Bold 49 font
- **Date** — day/month number (`DD/MM`) in Gothic 28 Bold below the time
- **Countdown bar** — 8px bar across the top of the screen:
- Starts full at the top of each half hour and drains to empty as it approaches `:00` or `:30`
- If a calendar appointment is within the next 30 minutes, the bar counts down to that instead
- Requires Google Calendar API setup (see below); falls back to half-hour countdown without it
- **Battery bar** — thin 4px indicator along the bottom edge
## Supported platforms
`aplite`, `basalt`, `diorite`, `emery`, `flint`
## Building
Requires the [Pebble SDK](https://developer.rebble.io/developer.pebble.com/sdk/index.html) (version 3).
```bash
cd countdown_watchface
pebble build
```
Install on a connected watch or emulator:
```bash
pebble install --phone <watch-ip>
# or
pebble install --emulator basalt
```
## Calendar integration (optional)
The watchface uses Google Calendar API to detect appointments within the next 30 minutes. Without this, the bar simply counts down to the next `:00` or `:30`.
**Setup:**
1. Create a Google Cloud project and enable the **Google Calendar API**.
2. Generate an **API key** (restrict it to the Calendar API and your IP/app if desired).
3. In the Pebble phone app's JS console (or via a config page), run:
```javascript
localStorage.setItem('gcal_api_key', 'YOUR_API_KEY_HERE');
```
The calendar ID defaults to `primary`. To use a different calendar, edit `src/pkjs/index.js` and change the `CALENDAR_ID` variable.
The phone-side JS polls for events every minute and sends the minutes-to-appointment to the watch via AppMessage. If the API call fails or no key is set, the watch falls back to the half-hour countdown automatically.
+12 -5
View File
@@ -6,9 +6,7 @@
"pebble-app"
],
"private": true,
"dependencies": {
"pebble-effect-layer": "^1.2.0"
},
"dependencies": {},
"pebble": {
"displayName": "countdown_watchface",
"uuid": "769d0058-ae41-4e80-b41a-64d58e2ae3e0",
@@ -25,10 +23,19 @@
"watchface": true
},
"messageKeys": [
"dummy"
"BAR_CHUNK_MINS",
"DAY_START_HOUR",
"DAY_END_HOUR"
],
"resources": {
"media": []
"media": [
{
"type": "font",
"name": "FONT_ROBOTO_BOLD_36",
"file": "fonts/Roboto-Bold.ttf",
"maxHeight": 36
}
]
}
}
}
Binary file not shown.
+189 -69
View File
@@ -1,120 +1,240 @@
#include <pebble.h>
#include <pebble-effect-layer/pebble-effect-layer.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 EffectLayer *s_effect_layer;
static Layer *s_bar_layer;
static Layer *s_day_bar_layer;
static Layer *s_battery_layer;
static Layer *s_countdown_layer;
static int s_battery_level;
static GFont s_date_font;
static void battery_update_proc(Layer *layer, GContext *ctx) {
GRect bounds = layer_get_bounds(layer);
static const char * const MONTHS[] = {
"JAN","FEB","MAR","APR","MAY","JUN",
"JUL","AUG","SEP","OCT","NOV","DEC"
};
int width = (s_battery_level * 114)/ 100;
static int s_battery_level = 100;
static int s_bar_fill = 30;
static int s_day_bar_fill = 16;
graphics_context_set_fill_color(ctx, GColorWhite);
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 s_bar_chunk_mins - (t->tm_min % s_bar_chunk_mins);
}
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);
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 < 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, GRect(0,0,width,bounds.size.h), 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);
}
}
}
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);
graphics_fill_rect(ctx, bounds, 0, GCornerNone);
if (s_battery_level >= 20) return;
graphics_context_set_stroke_color(ctx, GColorWhite);
graphics_draw_rect(ctx, GRect(0, 1, 19, 10));
graphics_context_set_fill_color(ctx, GColorWhite);
graphics_fill_rect(ctx, GRect(19, 4, 3, 4), 0, GCornerNone);
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 inbox_received(DictionaryIterator *iter, void *context) {
Tuple *t;
t = dict_find(iter, MESSAGE_KEY_DAY_START_HOUR);
if (t) {
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) {
static char time_buf[6];
strftime(time_buf, sizeof(time_buf), "%H:%M", tick_time);
text_layer_set_text(s_time_layer, time_buf);
static char date_buf[8];
snprintf(date_buf, sizeof(date_buf), "%d %s", tick_time->tm_mday, MONTHS[tick_time->tm_mon]);
text_layer_set_text(s_date_layer, date_buf);
update_bar();
}
static void tick_handler(struct tm *tick_time, TimeUnits units_changed) {
update_display(tick_time);
}
static void battery_callback(BatteryChargeState state) {
s_battery_level = state.charge_percent;
layer_mark_dirty(s_battery_layer);
}
static void main_window_load(Window *window) {
Layer *window_layer = window_get_root_layer(window);
GRect bounds = layer_get_bounds(window_layer);
Layer *root = window_get_root_layer(window);
GRect bounds = layer_get_bounds(root);
s_effect_layer = effect_layer_create(bounds);
effect_layer_add_effect(s_effect_layer, effect_invert, NULL);
layer_add_child(window_layer,effect_layer_get_layer(s_effect_layer));
// 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;
s_time_layer = text_layer_create(
GRect(0, PBL_IF_ROUND_ELSE(58,52), bounds.size.w, 50)
);
// Improve the layout to be more like a watchface
#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));
#else
s_time_layer = text_layer_create(GRect(bar_x, 44, bar_w, 44));
text_layer_set_font(s_time_layer, fonts_get_system_font(FONT_KEY_LECO_42_NUMBERS));
#endif
text_layer_set_background_color(s_time_layer, GColorClear);
text_layer_set_text_color(s_time_layer, GColorWhite);
text_layer_set_text_alignment(s_time_layer, GTextAlignmentRight);
text_layer_set_text(s_time_layer, "00:00");
text_layer_set_font(s_time_layer, fonts_get_system_font(FONT_KEY_ROBOTO_BOLD_SUBSET_49));
text_layer_set_text_alignment(s_time_layer, GTextAlignmentCenter);
layer_add_child(root, text_layer_get_layer(s_time_layer));
// Add it as a child layer to the Window's root layer
layer_add_child(window_layer, text_layer_get_layer(s_time_layer));
// 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);
s_date_layer = text_layer_create(
GRect(0, PBL_IF_ROUND_ELSE(108,102), bounds.size.w, 50)
);
// 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);
// Improve the layout to be more like a watchface
// 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, 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_text(s_date_layer, "Wed 1st Jan");
text_layer_set_font(s_date_layer, fonts_get_system_font(FONT_KEY_ROBOTO_CONDENSED_21));
text_layer_set_text_alignment(s_date_layer, GTextAlignmentCenter);
text_layer_set_font(s_date_layer, s_date_font);
text_layer_set_text_alignment(s_date_layer, GTextAlignmentRight);
text_layer_set_text(s_date_layer, "1 JAN");
layer_add_child(root, text_layer_get_layer(s_date_layer));
// Add it as a child layer to the Window's root layer
layer_add_child(window_layer, text_layer_get_layer(s_date_layer));
// Battery
s_battery_layer = layer_create(GRect(14,54,115,2));
layer_set_update_proc(s_battery_layer, battery_update_proc);
layer_add_child(window_layer, s_battery_layer);
layer_mark_dirty(s_battery_layer);
// Battery icon — bottom right, only visible below 20%
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);
}
static void main_window_unload(Window *window) {
text_layer_destroy(s_time_layer);
text_layer_destroy(s_date_layer);
effect_layer_destroy(s_effect_layer);
layer_destroy(s_bar_layer);
layer_destroy(s_day_bar_layer);
layer_destroy(s_battery_layer);
fonts_unload_custom_font(s_date_font);
}
static void update_time() {
time_t temp = time(NULL);
struct tm *tick_time = localtime(&temp);
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;
static char s_buffer[8];
strftime(s_buffer, sizeof(s_buffer), clock_is_24h_style() ? "%H:%M" : "%I:%M", tick_time);
text_layer_set_text(s_time_layer, s_buffer);
static char d_buffer[12];
strftime(d_buffer, sizeof(d_buffer), "%a %d %b", tick_time);
text_layer_set_text(s_date_layer, d_buffer);
}
static void tick_handler(struct tm *tick_time, TimeUnits units_changed) {
update_time();
}
static void battery_callback(BatteryChargeState state) {
s_battery_level = state.charge_percent;
}
static void init() {
s_main_window = window_create();
window_set_background_color(s_main_window, GColorBlack);
window_set_window_handlers(s_main_window, (WindowHandlers) {
.load = main_window_load,
.unload = main_window_unload
.unload = main_window_unload,
});
window_stack_push(s_main_window, true);
tick_timer_service_subscribe(MINUTE_UNIT, tick_handler);
battery_state_service_subscribe(battery_callback);
update_time();
battery_callback(battery_state_service_peek());
app_message_register_inbox_received(inbox_received);
app_message_open(128, 0);
time_t now = time(NULL);
update_display(localtime(&now));
}
static void deinit() {
static void deinit(void) {
window_destroy(s_main_window);
}
+94
View File
@@ -0,0 +1,94 @@
// --- Configuration ---
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 sendConfig(cfg) {
Pebble.sendAppMessage(
{
'BAR_CHUNK_MINS': cfg.chunk,
'DAY_START_HOUR': cfg.start,
'DAY_END_HOUR': cfg.end
},
function() {},
function(e) { console.log('Config send failed: ' + JSON.stringify(e)); }
);
}
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() {
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);
}
});