Compare commits

...

8 Commits

Author SHA1 Message Date
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 296 additions and 76 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.
+10 -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,17 @@
"watchface": true
},
"messageKeys": [
"dummy"
"APPOINTMENT_MINUTES"
],
"resources": {
"media": []
"media": [
{
"type": "font",
"name": "FONT_ROBOTO_BOLD_36",
"file": "fonts/Roboto-Bold.ttf",
"maxHeight": 36
}
]
}
}
}
Binary file not shown.
+170 -71
View File
@@ -1,120 +1,219 @@
#include <pebble.h>
#include <pebble-effect-layer/pebble-effect-layer.h>
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_battery_layer;
static Layer *s_countdown_layer;
static int s_battery_level;
static Layer *s_calendar_layer;
static GFont s_date_font;
static void battery_update_proc(Layer *layer, GContext *ctx) {
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 mins_to_next_half(void) {
time_t now = time(NULL);
struct tm *t = localtime(&now);
return 30 - (t->tm_min % 30); // returns 130
}
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);
int width = (s_battery_level * 114)/ 100;
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;
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) {
graphics_context_set_fill_color(ctx, GColorWhite);
graphics_fill_rect(ctx, block, 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 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;
// 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,bounds,0,GCornerNone);
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);
}
}
graphics_context_set_fill_color(ctx,GColorWhite);
graphics_fill_rect(ctx, GRect(0,0,width,bounds.size.h), 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);
if (t) {
s_appt_mins = (int)t->value->int32;
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));
// Shared horizontal geometry matching the bar's block layout
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
// 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));
#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));
// Countdown bar — 30 blocks, right-to-left
s_bar_layer = layer_create(GRect(0, 90, bounds.size.w, 14));
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)
);
// Improve the layout to be more like a watchface
// Date — Roboto Bold 36 custom font, right-aligned to bar
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));
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);
// 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);
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 - 16, 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_calendar_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 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() {
static void init(void) {
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(64, 0);
time_t now = time(NULL);
update_display(localtime(&now));
}
static void deinit() {
static void deinit(void) {
window_destroy(s_main_window);
}
+64
View File
@@ -0,0 +1,64 @@
// 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.
var CALENDAR_ID = 'primary';
function sendAppointmentMinutes(minutes) {
Pebble.sendAppMessage(
{ 'APPOINTMENT_MINUTES': minutes },
function() {},
function(e) { console.log('AppMessage 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();
}
Pebble.addEventListener('ready', function() {
checkCalendar();
setInterval(checkCalendar, 60 * 1000);
});