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>
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
"pebble-app"
|
"pebble-app"
|
||||||
],
|
],
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {},
|
||||||
"pebble-effect-layer": "^1.2.0"
|
|
||||||
},
|
|
||||||
"pebble": {
|
"pebble": {
|
||||||
"displayName": "countdown_watchface",
|
"displayName": "countdown_watchface",
|
||||||
"uuid": "769d0058-ae41-4e80-b41a-64d58e2ae3e0",
|
"uuid": "769d0058-ae41-4e80-b41a-64d58e2ae3e0",
|
||||||
@@ -25,7 +23,7 @@
|
|||||||
"watchface": true
|
"watchface": true
|
||||||
},
|
},
|
||||||
"messageKeys": [
|
"messageKeys": [
|
||||||
"dummy"
|
"APPOINTMENT_MINUTES"
|
||||||
],
|
],
|
||||||
"resources": {
|
"resources": {
|
||||||
"media": []
|
"media": []
|
||||||
|
|||||||
@@ -1,120 +1,155 @@
|
|||||||
#include <pebble.h>
|
#include <pebble.h>
|
||||||
#include <pebble-effect-layer/pebble-effect-layer.h>
|
|
||||||
|
|
||||||
|
|
||||||
static Window *s_main_window;
|
static Window *s_main_window;
|
||||||
static TextLayer *s_time_layer;
|
static TextLayer *s_time_layer;
|
||||||
static TextLayer *s_date_layer;
|
static TextLayer *s_date_layer;
|
||||||
static EffectLayer *s_effect_layer;
|
static Layer *s_bar_layer;
|
||||||
static Layer *s_battery_layer;
|
static Layer *s_battery_layer;
|
||||||
static Layer *s_countdown_layer;
|
|
||||||
static int s_battery_level;
|
|
||||||
|
|
||||||
static void battery_update_proc(Layer *layer, GContext *ctx) {
|
static int s_battery_level = 100;
|
||||||
|
static int s_bar_fill = 100; // 0–100: 100 = 30 min remaining, 0 = event now
|
||||||
|
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 1–30
|
||||||
|
}
|
||||||
|
|
||||||
|
static void update_bar(void) {
|
||||||
|
int half_mins = mins_to_next_half();
|
||||||
|
int target = half_mins;
|
||||||
|
|
||||||
|
if (s_appt_mins >= 0 && s_appt_mins < half_mins) {
|
||||||
|
target = s_appt_mins;
|
||||||
|
}
|
||||||
|
|
||||||
|
s_bar_fill = (target * 100) / 30;
|
||||||
|
if (s_bar_fill > 100) s_bar_fill = 100;
|
||||||
|
if (s_bar_fill < 0) s_bar_fill = 0;
|
||||||
|
|
||||||
|
layer_mark_dirty(s_bar_layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void bar_draw(Layer *layer, GContext *ctx) {
|
||||||
GRect bounds = layer_get_bounds(layer);
|
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);
|
||||||
|
|
||||||
graphics_context_set_fill_color(ctx, GColorWhite);
|
int fill_w = (s_bar_fill * bounds.size.w) / 100;
|
||||||
graphics_fill_rect(ctx,bounds,0,GCornerNone);
|
if (fill_w > 0) {
|
||||||
|
graphics_context_set_fill_color(ctx, GColorWhite);
|
||||||
|
graphics_fill_rect(ctx, GRect(0, 0, fill_w, bounds.size.h), 0, GCornerNone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
graphics_context_set_fill_color(ctx,GColorWhite);
|
static void battery_draw(Layer *layer, GContext *ctx) {
|
||||||
graphics_fill_rect(ctx, GRect(0,0,width,bounds.size.h), 0, GCornerNone);
|
GRect bounds = layer_get_bounds(layer);
|
||||||
|
|
||||||
|
graphics_context_set_fill_color(ctx, GColorBlack);
|
||||||
|
graphics_fill_rect(ctx, bounds, 0, GCornerNone);
|
||||||
|
|
||||||
|
int fill_w = (s_battery_level * bounds.size.w) / 100;
|
||||||
|
if (fill_w > 0) {
|
||||||
|
graphics_context_set_fill_color(ctx, GColorWhite);
|
||||||
|
graphics_fill_rect(ctx, GRect(0, 0, fill_w, bounds.size.h), 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[6];
|
||||||
|
strftime(date_buf, sizeof(date_buf), "%d/%m", tick_time);
|
||||||
|
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) {
|
static void main_window_load(Window *window) {
|
||||||
Layer *window_layer = window_get_root_layer(window);
|
Layer *root = window_get_root_layer(window);
|
||||||
GRect bounds = layer_get_bounds(window_layer);
|
GRect bounds = layer_get_bounds(root);
|
||||||
|
|
||||||
s_effect_layer = effect_layer_create(bounds);
|
// Countdown bar — full width at top, 8px tall
|
||||||
effect_layer_add_effect(s_effect_layer, effect_invert, NULL);
|
s_bar_layer = layer_create(GRect(0, 0, bounds.size.w, 8));
|
||||||
layer_add_child(window_layer,effect_layer_get_layer(s_effect_layer));
|
layer_set_update_proc(s_bar_layer, bar_draw);
|
||||||
|
layer_add_child(root, s_bar_layer);
|
||||||
|
|
||||||
|
// Time — large 24h display
|
||||||
s_time_layer = text_layer_create(
|
s_time_layer = text_layer_create(
|
||||||
GRect(0, PBL_IF_ROUND_ELSE(58,52), bounds.size.w, 50)
|
GRect(0, PBL_IF_ROUND_ELSE(52, 44), bounds.size.w, 56));
|
||||||
);
|
|
||||||
|
|
||||||
// Improve the layout to be more like a watchface
|
|
||||||
text_layer_set_background_color(s_time_layer, GColorClear);
|
text_layer_set_background_color(s_time_layer, GColorClear);
|
||||||
text_layer_set_text_color(s_time_layer, GColorWhite);
|
text_layer_set_text_color(s_time_layer, GColorWhite);
|
||||||
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_font(s_time_layer, fonts_get_system_font(FONT_KEY_ROBOTO_BOLD_SUBSET_49));
|
||||||
text_layer_set_text_alignment(s_time_layer, GTextAlignmentCenter);
|
text_layer_set_text_alignment(s_time_layer, GTextAlignmentCenter);
|
||||||
|
text_layer_set_text(s_time_layer, "00:00");
|
||||||
|
layer_add_child(root, text_layer_get_layer(s_time_layer));
|
||||||
|
|
||||||
// Add it as a child layer to the Window's root layer
|
// Date — day/month, readable but smaller
|
||||||
layer_add_child(window_layer, text_layer_get_layer(s_time_layer));
|
|
||||||
|
|
||||||
s_date_layer = text_layer_create(
|
s_date_layer = text_layer_create(
|
||||||
GRect(0, PBL_IF_ROUND_ELSE(108,102), bounds.size.w, 50)
|
GRect(0, PBL_IF_ROUND_ELSE(114, 104), bounds.size.w, 32));
|
||||||
);
|
|
||||||
|
|
||||||
// Improve the layout to be more like a watchface
|
|
||||||
text_layer_set_background_color(s_date_layer, GColorClear);
|
text_layer_set_background_color(s_date_layer, GColorClear);
|
||||||
text_layer_set_text_color(s_date_layer, GColorWhite);
|
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_GOTHIC_28_BOLD));
|
||||||
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_text_alignment(s_date_layer, GTextAlignmentCenter);
|
||||||
|
text_layer_set_text(s_date_layer, "01/01");
|
||||||
|
layer_add_child(root, text_layer_get_layer(s_date_layer));
|
||||||
|
|
||||||
// Add it as a child layer to the Window's root layer
|
// Battery — thin strip at the very bottom
|
||||||
layer_add_child(window_layer, text_layer_get_layer(s_date_layer));
|
s_battery_layer = layer_create(GRect(0, bounds.size.h - 4, bounds.size.w, 4));
|
||||||
|
layer_set_update_proc(s_battery_layer, battery_draw);
|
||||||
// Battery
|
layer_add_child(root, s_battery_layer);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void main_window_unload(Window *window) {
|
static void main_window_unload(Window *window) {
|
||||||
text_layer_destroy(s_time_layer);
|
text_layer_destroy(s_time_layer);
|
||||||
text_layer_destroy(s_date_layer);
|
text_layer_destroy(s_date_layer);
|
||||||
effect_layer_destroy(s_effect_layer);
|
layer_destroy(s_bar_layer);
|
||||||
layer_destroy(s_battery_layer);
|
layer_destroy(s_battery_layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void update_time() {
|
static void init(void) {
|
||||||
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() {
|
|
||||||
s_main_window = window_create();
|
s_main_window = window_create();
|
||||||
|
window_set_background_color(s_main_window, GColorBlack);
|
||||||
|
|
||||||
window_set_window_handlers(s_main_window, (WindowHandlers) {
|
window_set_window_handlers(s_main_window, (WindowHandlers) {
|
||||||
.load = main_window_load,
|
.load = main_window_load,
|
||||||
.unload = main_window_unload
|
.unload = main_window_unload,
|
||||||
});
|
});
|
||||||
|
|
||||||
window_stack_push(s_main_window, true);
|
window_stack_push(s_main_window, true);
|
||||||
|
|
||||||
tick_timer_service_subscribe(MINUTE_UNIT, tick_handler);
|
tick_timer_service_subscribe(MINUTE_UNIT, tick_handler);
|
||||||
battery_state_service_subscribe(battery_callback);
|
battery_state_service_subscribe(battery_callback);
|
||||||
update_time();
|
|
||||||
battery_callback(battery_state_service_peek());
|
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);
|
window_destroy(s_main_window);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user