diff --git a/README.md b/README.md index e69de29..0901508 100644 --- a/README.md +++ b/README.md @@ -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 +# 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. diff --git a/countdown_watchface/package.json b/countdown_watchface/package.json index ce04c7c..fe62424 100644 --- a/countdown_watchface/package.json +++ b/countdown_watchface/package.json @@ -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,7 +23,7 @@ "watchface": true }, "messageKeys": [ - "dummy" + "APPOINTMENT_MINUTES" ], "resources": { "media": [] diff --git a/countdown_watchface/src/c/watchface.c b/countdown_watchface/src/c/watchface.c index 6e480c2..e81c7ce 100644 --- a/countdown_watchface/src/c/watchface.c +++ b/countdown_watchface/src/c/watchface.c @@ -1,120 +1,155 @@ #include -#include - 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 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); - 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); - graphics_fill_rect(ctx,bounds,0,GCornerNone); + int fill_w = (s_bar_fill * 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); + } +} - graphics_context_set_fill_color(ctx,GColorWhite); - graphics_fill_rect(ctx, GRect(0,0,width,bounds.size.h), 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); + + 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) { - 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)); + // Countdown bar — full width at top, 8px tall + s_bar_layer = layer_create(GRect(0, 0, bounds.size.w, 8)); + 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( - GRect(0, PBL_IF_ROUND_ELSE(58,52), bounds.size.w, 50) - ); - - // Improve the layout to be more like a watchface + GRect(0, PBL_IF_ROUND_ELSE(52, 44), bounds.size.w, 56)); text_layer_set_background_color(s_time_layer, GColorClear); 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_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 - layer_add_child(window_layer, text_layer_get_layer(s_time_layer)); - + // Date — day/month, readable but smaller 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 + GRect(0, PBL_IF_ROUND_ELSE(114, 104), bounds.size.w, 32)); 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_font(s_date_layer, fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD)); 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 - 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 — thin strip at the very bottom + s_battery_layer = layer_create(GRect(0, bounds.size.h - 4, bounds.size.w, 4)); + 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_battery_layer); } -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); } diff --git a/countdown_watchface/src/pkjs/index.js b/countdown_watchface/src/pkjs/index.js new file mode 100644 index 0000000..b9dda3b --- /dev/null +++ b/countdown_watchface/src/pkjs/index.js @@ -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); +});