Compare commits
2 Commits
6c239163de
...
2d242a48e1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d242a48e1 | |||
| d5b9c443a0 |
@@ -23,7 +23,9 @@
|
||||
"watchface": true
|
||||
},
|
||||
"messageKeys": [
|
||||
"APPOINTMENT_MINUTES"
|
||||
"BAR_CHUNK_MINS",
|
||||
"DAY_START_HOUR",
|
||||
"DAY_END_HOUR"
|
||||
],
|
||||
"resources": {
|
||||
"media": [
|
||||
|
||||
@@ -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; // 0–30: 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 1–30
|
||||
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));
|
||||
|
||||
@@ -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, 0–23)<input type="number" id="a" min="0" max="23"></label>' +
|
||||
'<label>Day end (hour, 0–23)<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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user