diff --git a/res/app_icon_18x28_64_color.png b/res/app_icon_18x28_64_color.png new file mode 100644 index 0000000..5f6905a Binary files /dev/null and b/res/app_icon_18x28_64_color.png differ diff --git a/res/app_icon_50x50_64_color.png b/res/app_icon_50x50_64_color.png new file mode 100644 index 0000000..f585d3c Binary files /dev/null and b/res/app_icon_50x50_64_color.png differ diff --git a/res/app_icon_64_gray.png b/res/app_icon_64_gray.png deleted file mode 100644 index 935dba4..0000000 Binary files a/res/app_icon_64_gray.png and /dev/null differ diff --git a/res/app_icon_color.xcf b/res/app_icon_color.xcf new file mode 100644 index 0000000..18f2dcf Binary files /dev/null and b/res/app_icon_color.xcf differ diff --git a/res/app_icon.xcf b/res/app_icon_gray.xcf similarity index 100% rename from res/app_icon.xcf rename to res/app_icon_gray.xcf diff --git a/watch/package.json b/watch/package.json index e6d721a..8a7325a 100644 --- a/watch/package.json +++ b/watch/package.json @@ -2,7 +2,9 @@ "name": "deck_in_a_dash_watch", "author": "Matthias Ladkau", "version": "1.0.0", - "keywords": ["pebble-app"], + "keywords": [ + "pebble-app" + ], "private": true, "dependencies": {}, "pebble": { @@ -10,6 +12,9 @@ "uuid": "7f47ed93-f44e-4c1a-9998-7edcfa61218e", "sdkVersion": "3", "enableMultiJS": true, + "capabilities": [ + "configurable" + ], "targetPlatforms": [ "basalt", "chalk", @@ -37,28 +42,116 @@ ], "resources": { "media": [ - { "type": "bitmap", "name": "IMG_CARD_FOOL", "file": "img/fool.png" }, - { "type": "bitmap", "name": "IMG_CARD_MAGICIAN", "file": "img/magician.png" }, - { "type": "bitmap", "name": "IMG_CARD_HIGH_PRIESTESS", "file": "img/high_priestess.png" }, - { "type": "bitmap", "name": "IMG_CARD_EMPRESS", "file": "img/empress.png" }, - { "type": "bitmap", "name": "IMG_CARD_EMPEROR", "file": "img/emperor.png" }, - { "type": "bitmap", "name": "IMG_CARD_HIEROPHANT", "file": "img/hierophant.png" }, - { "type": "bitmap", "name": "IMG_CARD_LOVERS", "file": "img/lovers.png" }, - { "type": "bitmap", "name": "IMG_CARD_CHARIOT", "file": "img/chariot.png" }, - { "type": "bitmap", "name": "IMG_CARD_STRENGTH", "file": "img/strength.png" }, - { "type": "bitmap", "name": "IMG_CARD_HERMIT", "file": "img/hermit.png" }, - { "type": "bitmap", "name": "IMG_CARD_WHEEL_OF_FORTUNE", "file": "img/wheel_of_fortune.png" }, - { "type": "bitmap", "name": "IMG_CARD_JUSTICE", "file": "img/justice.png" }, - { "type": "bitmap", "name": "IMG_CARD_HANGED_MAN", "file": "img/hanged_man.png" }, - { "type": "bitmap", "name": "IMG_CARD_DEATH", "file": "img/death.png" }, - { "type": "bitmap", "name": "IMG_CARD_TEMPERANCE", "file": "img/temperance.png" }, - { "type": "bitmap", "name": "IMG_CARD_DEVIL", "file": "img/devil.png" }, - { "type": "bitmap", "name": "IMG_CARD_TOWER", "file": "img/tower.png" }, - { "type": "bitmap", "name": "IMG_CARD_STAR", "file": "img/star.png" }, - { "type": "bitmap", "name": "IMG_CARD_MOON", "file": "img/moon.png" }, - { "type": "bitmap", "name": "IMG_CARD_SUN", "file": "img/sun.png" }, - { "type": "bitmap", "name": "IMG_CARD_JUDGEMENT", "file": "img/judgement.png" }, - { "type": "bitmap", "name": "IMG_CARD_WORLD", "file": "img/world.png" } + { + "type": "bitmap", + "name": "IMG_CARD_FOOL", + "file": "img/fool.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_MAGICIAN", + "file": "img/magician.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_HIGH_PRIESTESS", + "file": "img/high_priestess.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_EMPRESS", + "file": "img/empress.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_EMPEROR", + "file": "img/emperor.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_HIEROPHANT", + "file": "img/hierophant.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_LOVERS", + "file": "img/lovers.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_CHARIOT", + "file": "img/chariot.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_STRENGTH", + "file": "img/strength.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_HERMIT", + "file": "img/hermit.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_WHEEL_OF_FORTUNE", + "file": "img/wheel_of_fortune.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_JUSTICE", + "file": "img/justice.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_HANGED_MAN", + "file": "img/hanged_man.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_DEATH", + "file": "img/death.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_TEMPERANCE", + "file": "img/temperance.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_DEVIL", + "file": "img/devil.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_TOWER", + "file": "img/tower.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_STAR", + "file": "img/star.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_MOON", + "file": "img/moon.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_SUN", + "file": "img/sun.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_JUDGEMENT", + "file": "img/judgement.png" + }, + { + "type": "bitmap", + "name": "IMG_CARD_WORLD", + "file": "img/world.png" + } ] } } diff --git a/watch/scripts/gen_card_images.py b/watch/scripts/gen_card_images.py index e89b412..7df80a0 100644 --- a/watch/scripts/gen_card_images.py +++ b/watch/scripts/gen_card_images.py @@ -3,21 +3,20 @@ The 22 Major Arcana JPEGs in res/img/ are ~1MB/~1086x1810px each - suitable for the desktop HTML reading (an tag pointed at the file -as-is) but far too large for a Pebble app's own resource budget (256KB -total on basalt-class platforms, shared with every other resource this -app ships). This script resizes each to CARD_WIDTH px wide (preserving -aspect ratio) and writes a PNG per card, named after tarot_data.c's own -k_card_slug table so watch/package.json's "resources.media" entries -(IMG_CARD_, file img/.png) line up without a -separate mapping table anywhere. +as-is) but far too large for a Pebble app's own resource budget. This +script resizes each to CARD_WIDTH px wide (preserving aspect ratio) and +writes a PNG per card, named after tarot_data.c's own k_card_slug table +so watch/package.json's "resources.media" entries (IMG_CARD_, file img/.png) line up without a separate mapping +table anywhere. -CARD_WIDTH=72 (-> 72x120) was picked empirically: a real `pebble build` -of two sample cards at this size measured ~5KB/card on color platforms -(basalt/chalk/emery/gabbro) and ~2.9KB/card on the black-and-white -diorite/flint - so all 22 cards together cost roughly 111KB/64KB -respectively, comfortably inside the 256KB resource budget with room -left for other future resources (fonts, icons), while still rendering -at close to a full basalt-class screen's height (120px of 168px). +CARD_WIDTH=72 (-> 72x120) is a hardware-verified ceiling, not a design +choice: full-display-width images (144-260px depending on platform) were +tried first and reliably crash real hardware with "PNG memory allocation +failed" - a runtime PNG-decoder memory constraint independent of the +app's own heap budget (lazy-loading only one image at a time didn't help +either). 72px is the size the original per-card screens used +successfully all session before that experiment. Generated output, like i18n_tables.auto.c - not committed (see .gitignore), regenerate by running this script directly or via the root diff --git a/watch/src/c/app_message.c b/watch/src/c/app_message.c index e14acb8..c36fc90 100644 --- a/watch/src/c/app_message.c +++ b/watch/src/c/app_message.c @@ -60,6 +60,7 @@ static void inbox_received_handler(DictionaryIterator *iter, void *context) { * of the settings page) must not un-configure an already-set-up watch. */ if (got_birth) cfg.configured = true; + config_log(&cfg); /* confirms what was actually received/parsed */ config_save(&cfg); if (s_callback) s_callback(&cfg); } diff --git a/watch/src/c/config.c b/watch/src/c/config.c index 6a5c8b0..27f2995 100644 --- a/watch/src/c/config.c +++ b/watch/src/c/config.c @@ -68,3 +68,19 @@ void config_save(const WatchConfig *cfg) { persist_write_int(PKEY_NOTIFY_HOUR, cfg->notify_hour); persist_write_int(PKEY_NOTIFY_MINUTE, cfg->notify_minute); } + +/* Cast to int/long rather than %f - Pebble's APP_LOG doesn't support + * floating-point format specifiers. */ +void config_log(const WatchConfig *cfg) { + if (!cfg->configured) { + APP_LOG(APP_LOG_LEVEL_INFO, "config: not configured yet"); + return; + } + APP_LOG(APP_LOG_LEVEL_INFO, "config: birth %04d-%02d-%02d %02d:%02d UTC offset %dmin", + cfg->birth.year, cfg->birth.month, cfg->birth.day, cfg->birth.hour, cfg->birth.minute, + (int)(cfg->birth.utc_offset_hours * 60)); + APP_LOG(APP_LOG_LEVEL_INFO, "config: lat=%ld/1e6 lon=%ld/1e6 lang=%s", + (long)(cfg->birth.latitude * 1000000), (long)(cfg->birth.longitude * 1000000), cfg->lang); + APP_LOG(APP_LOG_LEVEL_INFO, "config: notify_enabled=%d at %02d:%02d", + cfg->notify_enabled, cfg->notify_hour, cfg->notify_minute); +} diff --git a/watch/src/c/config.h b/watch/src/c/config.h index 2894fa1..0bfc87f 100644 --- a/watch/src/c/config.h +++ b/watch/src/c/config.h @@ -28,4 +28,10 @@ void config_load(WatchConfig *out); void config_save(const WatchConfig *cfg); +/* Prints `cfg` via APP_LOG - the same fields a reading is computed from, + * so callers can confirm on-device what values a shown reading is + * actually based on (there's no way to read them back from the watch + * otherwise). Prints "not configured yet" instead if !cfg->configured. */ +void config_log(const WatchConfig *cfg); + #endif diff --git a/watch/src/c/main.c b/watch/src/c/main.c index c2657a9..51732ee 100644 --- a/watch/src/c/main.c +++ b/watch/src/c/main.c @@ -5,7 +5,7 @@ #include "config.h" #include "i18n_tables.h" #include "reading_state.h" -#include "ui_menu_window.h" +#include "ui_report_window.h" #include "ui_text_window.h" static WatchConfig s_config; @@ -24,11 +24,12 @@ static void on_config_updated(const WatchConfig *cfg) { if (!s_config.configured) return; + config_log(&s_config); /* the values this newly-recomputed reading is based on */ reading_state_recompute(&s_config.birth); if (was_configured) { - menu_window_reload(); + report_window_reload(); } else { - menu_window_push(); + report_window_push(); } } @@ -37,14 +38,16 @@ int main(void) { watch_i18n_load(s_config.lang); app_message_init(on_config_updated); + config_log(&s_config); /* the values this session's reading (if any) is based on */ + if (s_config.configured) { reading_state_recompute(&s_config.birth); - menu_window_push(); + report_window_push(); } else { show_setup_required(); } app_event_loop(); - menu_window_deinit(); + report_window_deinit(); } diff --git a/watch/src/c/resource_map.c b/watch/src/c/resource_map.c index 3442a09..1ec9916 100644 --- a/watch/src/c/resource_map.c +++ b/watch/src/c/resource_map.c @@ -29,3 +29,7 @@ uint32_t card_resource_id(TarotCard card) { } return RESOURCE_ID_IMG_CARD_FOOL; /* unreachable for a valid TarotCard */ } + +int16_t card_image_height_for_width(int16_t width) { + return (int16_t)((width * 1810 + 543) / 1086); /* +543 = round, not truncate */ +} diff --git a/watch/src/c/resource_map.h b/watch/src/c/resource_map.h index 7ab9b77..82edf21 100644 --- a/watch/src/c/resource_map.h +++ b/watch/src/c/resource_map.h @@ -15,4 +15,10 @@ * place in the app that switches over. */ uint32_t card_resource_id(TarotCard card); +/* Height in pixels of a card image resized to `width` wide, preserving + * the source art's fixed 1086x1810 aspect ratio - matches + * gen_card_images.py's own resize math, so this stays correct for + * whichever per-platform width variant package.json resolves to. */ +int16_t card_image_height_for_width(int16_t width); + #endif diff --git a/watch/src/c/ui_card_window.c b/watch/src/c/ui_card_window.c deleted file mode 100644 index c4c4905..0000000 --- a/watch/src/c/ui_card_window.c +++ /dev/null @@ -1,78 +0,0 @@ -#include "ui_card_window.h" - -#include - -#include "resource_map.h" - -/* Matches the fixed size gen_card_images.py resizes every card to. */ -#define IMAGE_WIDTH 72 -#define IMAGE_HEIGHT 120 - -typedef struct { - TarotCard card; - char *text; /* title + "\n" + body, combined into one scrollable block */ - GBitmap *bitmap; - BitmapLayer *bitmap_layer; - ScrollLayer *scroll_layer; - TextLayer *text_layer; -} CardWindowState; - -static void window_load(Window *window) { - CardWindowState *state = window_get_user_data(window); - Layer *window_layer = window_get_root_layer(window); - GRect bounds = layer_get_bounds(window_layer); - - state->bitmap = gbitmap_create_with_resource(card_resource_id(state->card)); - int image_x = (bounds.size.w - IMAGE_WIDTH) / 2; - state->bitmap_layer = bitmap_layer_create(GRect(image_x, 0, IMAGE_WIDTH, IMAGE_HEIGHT)); - bitmap_layer_set_bitmap(state->bitmap_layer, state->bitmap); - bitmap_layer_set_alignment(state->bitmap_layer, GAlignCenter); - layer_add_child(window_layer, bitmap_layer_get_layer(state->bitmap_layer)); - - GRect scroll_bounds = GRect(0, IMAGE_HEIGHT, bounds.size.w, bounds.size.h - IMAGE_HEIGHT); - state->scroll_layer = scroll_layer_create(scroll_bounds); - scroll_layer_set_click_config_onto_window(state->scroll_layer, window); - - state->text_layer = text_layer_create(GRect(0, 0, scroll_bounds.size.w, 2000)); - text_layer_set_font(state->text_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18)); - text_layer_set_text(state->text_layer, state->text); - text_layer_set_overflow_mode(state->text_layer, GTextOverflowModeWordWrap); - - GSize content_size = text_layer_get_content_size(state->text_layer); - content_size.w = scroll_bounds.size.w; - content_size.h += 4; - text_layer_set_size(state->text_layer, content_size); - scroll_layer_set_content_size(state->scroll_layer, content_size); - - scroll_layer_add_child(state->scroll_layer, text_layer_get_layer(state->text_layer)); - layer_add_child(window_layer, scroll_layer_get_layer(state->scroll_layer)); -} - -static void window_unload(Window *window) { - CardWindowState *state = window_get_user_data(window); - bitmap_layer_destroy(state->bitmap_layer); - gbitmap_destroy(state->bitmap); - text_layer_destroy(state->text_layer); - scroll_layer_destroy(state->scroll_layer); - free(state->text); - free(state); - window_destroy(window); -} - -void card_window_push(TarotCard card, const char *title, const char *body) { - CardWindowState *state = malloc(sizeof(CardWindowState)); - memset(state, 0, sizeof *state); - state->card = card; - - size_t len = strlen(title) + 1 + strlen(body) + 1; - state->text = malloc(len); - snprintf(state->text, len, "%s\n%s", title, body); - - Window *window = window_create(); - window_set_user_data(window, state); - window_set_window_handlers(window, (WindowHandlers){ - .load = window_load, - .unload = window_unload, - }); - window_stack_push(window, true); -} diff --git a/watch/src/c/ui_card_window.h b/watch/src/c/ui_card_window.h deleted file mode 100644 index 5d45e63..0000000 --- a/watch/src/c/ui_card_window.h +++ /dev/null @@ -1,12 +0,0 @@ -#ifndef WATCH_UI_CARD_WINDOW_H -#define WATCH_UI_CARD_WINDOW_H - -#include "../../../engine/src/tarot.h" - -/* Pushes a window showing `card`'s art (via resource_map.h) pinned at - * the top, with `title` and `body` below it as scrollable text (e.g. the - * Celtic Cross position name + orientation, and the position description - * + card meaning). Copies both strings, same as text_window_push(). */ -void card_window_push(TarotCard card, const char *title, const char *body); - -#endif diff --git a/watch/src/c/ui_menu_window.c b/watch/src/c/ui_menu_window.c deleted file mode 100644 index 4ec70ee..0000000 --- a/watch/src/c/ui_menu_window.c +++ /dev/null @@ -1,191 +0,0 @@ -#include "ui_menu_window.h" - -#include - -#include "../../../engine/src/i18n.h" -#include "../../../interpreter/src/guidance.h" -#include "../../../interpreter/src/narrative.h" -#include "reading_state.h" -#include "ui_card_window.h" -#include "ui_text_window.h" - -typedef enum { - SECTION_TODAY = 0, - SECTION_TRANSITS, - SECTION_CELTIC_CROSS, - NUM_SECTIONS -} MenuSection; - -static Window *s_window; -static MenuLayer *s_menu_layer; - -/* Small local day-level name table, same duplication policy as - * interpreter/src/main.c's own day_level_name() - see significance.c's - * comment on why each independently-testable/linkable module keeps its - * own copy rather than sharing one across translation units that aren't - * otherwise coupled. */ -static const char *day_level_name(DaySignificance level) { - switch (level) { - case DAY_QUIET: return i18n_get("interp.day_level.quiet", "Quiet"); - case DAY_NOTABLE: return i18n_get("interp.day_level.notable", "Notable"); - case DAY_SIGNIFICANT: return i18n_get("interp.day_level.significant", "Significant"); - case DAY_MAJOR: return i18n_get("interp.day_level.major", "Major"); - default: return ""; - } -} - -static const char *reversed_marker(bool reversed) { - return reversed ? i18n_get("ui.reversed", "(Reversed)") : ""; -} - -static uint16_t get_num_sections_callback(MenuLayer *menu_layer, void *data) { - return NUM_SECTIONS; -} - -static uint16_t get_num_rows_callback(MenuLayer *menu_layer, uint16_t section_index, void *data) { - const DailyInterpretation *interp = reading_state_get_interpretation(); - switch (section_index) { - case SECTION_TODAY: return 1; - case SECTION_TRANSITS: return interp->top_item_count > 0 ? interp->top_item_count : 1; - case SECTION_CELTIC_CROSS: return TAROT_SPREAD_SIZE; - default: return 0; - } -} - -static int16_t get_header_height_callback(MenuLayer *menu_layer, uint16_t section_index, void *data) { - return MENU_CELL_BASIC_HEADER_HEIGHT; -} - -static void draw_header_callback(GContext *ctx, const Layer *cell_layer, uint16_t section_index, void *data) { - const char *title = ""; - switch (section_index) { - case SECTION_TODAY: title = i18n_get("interp.heading_day_significance", "Day Significance"); break; - case SECTION_TRANSITS: title = i18n_get("interp.heading_transits", "Significant Transits"); break; - case SECTION_CELTIC_CROSS: title = i18n_get("ui.celtic_cross", "Celtic Cross"); break; - } - menu_cell_basic_header_draw(ctx, cell_layer, title); -} - -static void draw_row_callback(GContext *ctx, const Layer *cell_layer, MenuIndex *cell_index, void *data) { - const DailyReading *reading = reading_state_get_reading(); - const DailyInterpretation *interp = reading_state_get_interpretation(); - - char title[48]; - char subtitle[64]; - - switch (cell_index->section) { - case SECTION_TODAY: - snprintf(title, sizeof title, "%s (%d/%d)", day_level_name(interp->day_level), - interp->day_level + 1, DAY_SIGNIFICANCE_COUNT); - menu_cell_basic_draw(ctx, cell_layer, title, NULL, NULL); - break; - - case SECTION_TRANSITS: - if (interp->top_item_count == 0) { - menu_cell_basic_draw(ctx, cell_layer, - i18n_get("interp.no_notable_transits", "No notable transits today."), - NULL, NULL); - break; - } - { - const Aspect *a = &interp->top_items[cell_index->row].aspect; - snprintf(title, sizeof title, "%s %s", i18n_get("ui.transiting", "Transiting"), - astro_body_name(a->transiting_planet)); - snprintf(subtitle, sizeof subtitle, "%s %s %s", astro_aspect_name(a->type), - i18n_get("ui.natal", "natal"), astro_body_name(a->natal_planet)); - menu_cell_basic_draw(ctx, cell_layer, title, subtitle, NULL); - } - break; - - case SECTION_CELTIC_CROSS: { - const TarotDraw *draw = &reading->spread.positions[cell_index->row]; - snprintf(title, sizeof title, "%s", tarot_position_name((CelticCrossPosition)cell_index->row)); - snprintf(subtitle, sizeof subtitle, "%s %s", tarot_card_name(draw->card), - reversed_marker(draw->reversed)); - menu_cell_basic_draw(ctx, cell_layer, title, subtitle, NULL); - break; - } - } -} - -static void select_callback(MenuLayer *menu_layer, MenuIndex *cell_index, void *data) { - const DailyReading *reading = reading_state_get_reading(); - const DailyInterpretation *interp = reading_state_get_interpretation(); - - switch (cell_index->section) { - case SECTION_TODAY: { - char body[GUIDANCE_TEXT_MAX]; - guidance_write(body, sizeof body, "", interp, &reading->spread); - text_window_push(i18n_get("interp.heading_day_significance", "Day Significance"), body); - break; - } - - case SECTION_TRANSITS: { - if (interp->top_item_count == 0) break; - const Aspect *a = &interp->top_items[cell_index->row].aspect; - char title[48]; - snprintf(title, sizeof title, "%s %s", i18n_get("ui.transiting", "Transiting"), - astro_body_name(a->transiting_planet)); - char body[NARRATIVE_TEXT_MAX]; - narrative_write(body, sizeof body, "", a, reading->transits.bodies[a->transiting_planet].house); - text_window_push(title, body); - break; - } - - case SECTION_CELTIC_CROSS: { - const TarotDraw *draw = &reading->spread.positions[cell_index->row]; - char title[64]; - snprintf(title, sizeof title, "%s: %s %s", - tarot_position_name((CelticCrossPosition)cell_index->row), - tarot_card_name(draw->card), reversed_marker(draw->reversed)); - char body[600]; - snprintf(body, sizeof body, "%s\n\n%s", - tarot_position_description((CelticCrossPosition)cell_index->row), - tarot_card_meaning(draw->card, draw->reversed)); - card_window_push(draw->card, title, body); - break; - } - } -} - -static void window_load(Window *window) { - Layer *window_layer = window_get_root_layer(window); - GRect bounds = layer_get_bounds(window_layer); - - s_menu_layer = menu_layer_create(bounds); - menu_layer_set_callbacks(s_menu_layer, NULL, (MenuLayerCallbacks){ - .get_num_sections = get_num_sections_callback, - .get_num_rows = get_num_rows_callback, - .get_header_height = get_header_height_callback, - .draw_header = draw_header_callback, - .draw_row = draw_row_callback, - .select_click = select_callback, - }); - menu_layer_set_click_config_onto_window(s_menu_layer, window); - layer_add_child(window_layer, menu_layer_get_layer(s_menu_layer)); -} - -static void window_unload(Window *window) { - menu_layer_destroy(s_menu_layer); - s_menu_layer = NULL; -} - -void menu_window_push(void) { - if (!s_window) { - s_window = window_create(); - window_set_window_handlers(s_window, (WindowHandlers){ - .load = window_load, - .unload = window_unload, - }); - } - window_stack_push(s_window, true); -} - -void menu_window_reload(void) { - if (s_menu_layer) menu_layer_reload_data(s_menu_layer); -} - -void menu_window_deinit(void) { - if (s_window) window_destroy(s_window); - s_window = NULL; -} diff --git a/watch/src/c/ui_menu_window.h b/watch/src/c/ui_menu_window.h deleted file mode 100644 index 5ece5da..0000000 --- a/watch/src/c/ui_menu_window.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef WATCH_UI_MENU_WINDOW_H -#define WATCH_UI_MENU_WINDOW_H - -/* The app's root screen: a 3-section MenuLayer (Day Significance / - * Significant Transits / Celtic Cross) reading directly from - * reading_state.h's current reading - see reading.h's own doc comment - * ("walks the returned struct directly to lay out its own screens"). - * Selecting a row pushes a detail screen (ui_text_window.h for prose, - * ui_card_window.h for a Celtic Cross position). A persistent singleton - - * created once and left at the bottom of the window stack; call - * menu_window_push() once at startup (and again if the day rolls over - * and the underlying reading changes - the MenuLayer re-reads - * reading_state.h on every redraw, so no explicit refresh call is - * needed beyond menu_layer_reload_data()). */ -void menu_window_push(void); -void menu_window_reload(void); -void menu_window_deinit(void); - -#endif diff --git a/watch/src/c/ui_report_window.c b/watch/src/c/ui_report_window.c new file mode 100644 index 0000000..1deb4e7 --- /dev/null +++ b/watch/src/c/ui_report_window.c @@ -0,0 +1,243 @@ +#include "ui_report_window.h" + +#include + +#include "../../../engine/src/i18n.h" +#include "../../../interpreter/src/guidance.h" +#include "../../../interpreter/src/narrative.h" +#include "reading_state.h" +#include "resource_map.h" + +#define MARGIN 6 + +/* Matches gen_card_images.py's own CARD_WIDTH - a hardware-verified + * ceiling, not a design choice: full-display-width images reliably + * crashed the real PNG decoder ("PNG memory allocation failed", a + * firmware-level constraint independent of this app's own heap budget, + * confirmed unaffected by lazy-loading only one image at a time). */ +#define IMAGE_WIDTH 72 + +/* fonts_get_system_font() needs Pebble's runtime initialized, so these + * are resolved once in window_load() rather than at static-init time. */ +static GFont s_font_section; +static GFont s_font_subhead; +static GFont s_font_body; + +/* Static buffers for every dynamically-built string this window shows - + * tarot_position_description()/tarot_card_meaning()/i18n_get() fallback + * text all point straight at flash/ROM data (see i18n.h's own comment on + * i18n_load_table()), so only the pieces actually assembled at runtime + * (via snprintf/narrative_write/guidance_write) need their own storage. + * Static rather than malloc'd - fixed, known-in-advance sizes, and it + * keeps report_window_reload() from having to reason about freeing the + * previous run's buffers before reusing them. */ +static char s_day_title[64]; +static char s_transit_titles[MAX_SIGNIFICANT_ITEMS][96]; +static char s_narratives[MAX_SIGNIFICANT_ITEMS][NARRATIVE_TEXT_MAX]; +static char s_position_titles[TAROT_SPREAD_SIZE][96]; +static char s_guidance[GUIDANCE_TEXT_MAX]; + +/* Loads/frees each position's GBitmap as scroll_offset_changed() sees it + * enter/leave the visible viewport, rather than decoding all ten Celtic + * Cross images up front - a defensive habit left over from an earlier, + * larger IMAGE_WIDTH (see that constant's own comment); at 72px this + * struct's images are small enough that it's no longer strictly + * necessary, but it's harmless to keep. */ +typedef struct { + TarotCard card; + int16_t y; + int16_t height; + BitmapLayer *bitmap_layer; + GBitmap *bitmap; +} ImageSlot; + +static ImageSlot s_slots[TAROT_SPREAD_SIZE]; + +#define MAX_TEXT_LAYERS 50 +static TextLayer *s_text_layers[MAX_TEXT_LAYERS]; +static int s_text_layer_count; + +static Window *s_window; +static ScrollLayer *s_scroll_layer; +static int16_t s_screen_height; + +static const char *day_level_name(DaySignificance level) { + switch (level) { + case DAY_QUIET: return i18n_get("interp.day_level.quiet", "Quiet"); + case DAY_NOTABLE: return i18n_get("interp.day_level.notable", "Notable"); + case DAY_SIGNIFICANT: return i18n_get("interp.day_level.significant", "Significant"); + case DAY_MAJOR: return i18n_get("interp.day_level.major", "Major"); + default: return ""; + } +} + +static const char *reversed_marker(bool reversed) { + return reversed ? i18n_get("ui.reversed", "(Reversed)") : ""; +} + +static int16_t add_text(int16_t width, int16_t y, const char *text, GFont font) { + TextLayer *tl = text_layer_create(GRect(0, y, width, 4000)); + text_layer_set_font(tl, font); + text_layer_set_text(tl, text); + text_layer_set_overflow_mode(tl, GTextOverflowModeWordWrap); + + GSize size = text_layer_get_content_size(tl); + size.w = width; + size.h += 4; + text_layer_set_size(tl, size); + + scroll_layer_add_child(s_scroll_layer, text_layer_get_layer(tl)); + s_text_layers[s_text_layer_count++] = tl; + return y + size.h + MARGIN; +} + +static void teardown_content(void) { + for (int i = 0; i < s_text_layer_count; i++) { + text_layer_destroy(s_text_layers[i]); + } + s_text_layer_count = 0; + + for (int i = 0; i < TAROT_SPREAD_SIZE; i++) { + if (s_slots[i].bitmap) { + gbitmap_destroy(s_slots[i].bitmap); + s_slots[i].bitmap = NULL; + } + if (s_slots[i].bitmap_layer) { + bitmap_layer_destroy(s_slots[i].bitmap_layer); + s_slots[i].bitmap_layer = NULL; + } + } +} + +static void update_visible_images(void) { + GPoint offset = scroll_layer_get_content_offset(s_scroll_layer); + int16_t visible_top = -offset.y; + int16_t visible_bottom = visible_top + s_screen_height; + + for (int i = 0; i < TAROT_SPREAD_SIZE; i++) { + ImageSlot *slot = &s_slots[i]; + bool visible = slot->y < visible_bottom && slot->y + slot->height > visible_top; + + if (visible && !slot->bitmap) { + slot->bitmap = gbitmap_create_with_resource(card_resource_id(slot->card)); + bitmap_layer_set_bitmap(slot->bitmap_layer, slot->bitmap); + } else if (!visible && slot->bitmap) { + bitmap_layer_set_bitmap(slot->bitmap_layer, NULL); + gbitmap_destroy(slot->bitmap); + slot->bitmap = NULL; + } + } +} + +static void scroll_offset_changed(ScrollLayer *scroll_layer, void *context) { + update_visible_images(); +} + +static void build_content(int16_t width) { + const DailyReading *reading = reading_state_get_reading(); + const DailyInterpretation *interp = reading_state_get_interpretation(); + + int16_t y = MARGIN; + + y = add_text(width, y, i18n_get("interp.heading_day_significance", "Day Significance"), s_font_section); + snprintf(s_day_title, sizeof s_day_title, "%s (%d/%d)", day_level_name(interp->day_level), + interp->day_level + 1, DAY_SIGNIFICANCE_COUNT); + y = add_text(width, y, s_day_title, s_font_body); + + y = add_text(width, y, i18n_get("interp.heading_transits", "Significant Transits"), s_font_section); + if (interp->top_item_count == 0) { + y = add_text(width, y, i18n_get("interp.no_notable_transits", "No notable transits today."), s_font_body); + } else { + for (int i = 0; i < interp->top_item_count; i++) { + const Aspect *a = &interp->top_items[i].aspect; + snprintf(s_transit_titles[i], sizeof s_transit_titles[i], "%s %s %s %s %s", + i18n_get("ui.transiting", "Transiting"), astro_body_name(a->transiting_planet), + astro_aspect_name(a->type), i18n_get("ui.natal", "natal"), + astro_body_name(a->natal_planet)); + y = add_text(width, y, s_transit_titles[i], s_font_subhead); + + narrative_write(s_narratives[i], sizeof s_narratives[i], "", a, + reading->transits.bodies[a->transiting_planet].house); + y = add_text(width, y, s_narratives[i], s_font_body); + } + } + + y = add_text(width, y, i18n_get("ui.celtic_cross", "Celtic Cross"), s_font_section); + for (int i = 0; i < TAROT_SPREAD_SIZE; i++) { + const TarotDraw *draw = &reading->spread.positions[i]; + + snprintf(s_position_titles[i], sizeof s_position_titles[i], "%s: %s %s", + tarot_position_name((CelticCrossPosition)i), tarot_card_name(draw->card), + reversed_marker(draw->reversed)); + y = add_text(width, y, s_position_titles[i], s_font_subhead); + + s_slots[i].card = draw->card; + s_slots[i].y = y; + s_slots[i].height = card_image_height_for_width(IMAGE_WIDTH); + int16_t image_x = (width - IMAGE_WIDTH) / 2; + s_slots[i].bitmap_layer = bitmap_layer_create(GRect(image_x, y, IMAGE_WIDTH, s_slots[i].height)); + bitmap_layer_set_alignment(s_slots[i].bitmap_layer, GAlignCenter); + scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_slots[i].bitmap_layer)); + y += s_slots[i].height + MARGIN; + + y = add_text(width, y, tarot_position_description((CelticCrossPosition)i), s_font_body); + y = add_text(width, y, tarot_card_meaning(draw->card, draw->reversed), s_font_body); + } + + y = add_text(width, y, i18n_get("interp.heading_guidance", "Guidance"), s_font_section); + guidance_write(s_guidance, sizeof s_guidance, "", interp, &reading->spread); + y = add_text(width, y, s_guidance, s_font_body); + + scroll_layer_set_content_size(s_scroll_layer, GSize(width, y)); +} + +static void window_load(Window *window) { + s_font_section = fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD); + s_font_subhead = fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD); + s_font_body = fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD); + + Layer *window_layer = window_get_root_layer(window); + GRect bounds = layer_get_bounds(window_layer); + s_screen_height = bounds.size.h; + + s_scroll_layer = scroll_layer_create(bounds); + scroll_layer_set_click_config_onto_window(s_scroll_layer, window); + scroll_layer_set_callbacks(s_scroll_layer, (ScrollLayerCallbacks){ + .content_offset_changed_handler = scroll_offset_changed, + }); + layer_add_child(window_layer, scroll_layer_get_layer(s_scroll_layer)); + + build_content(bounds.size.w); + update_visible_images(); +} + +static void window_unload(Window *window) { + teardown_content(); + scroll_layer_destroy(s_scroll_layer); + s_scroll_layer = NULL; +} + +void report_window_push(void) { + if (!s_window) { + s_window = window_create(); + window_set_window_handlers(s_window, (WindowHandlers){ + .load = window_load, + .unload = window_unload, + }); + } + window_stack_push(s_window, true); +} + +void report_window_reload(void) { + if (!s_window || !s_scroll_layer) return; + Layer *window_layer = window_get_root_layer(s_window); + int16_t width = layer_get_bounds(window_layer).size.w; + teardown_content(); + build_content(width); + update_visible_images(); +} + +void report_window_deinit(void) { + if (s_window) window_destroy(s_window); + s_window = NULL; +} diff --git a/watch/src/c/ui_report_window.h b/watch/src/c/ui_report_window.h new file mode 100644 index 0000000..4ba602e --- /dev/null +++ b/watch/src/c/ui_report_window.h @@ -0,0 +1,23 @@ +#ifndef WATCH_UI_REPORT_WINDOW_H +#define WATCH_UI_REPORT_WINDOW_H + +/* The app's only real screen: one continuously scrollable page mirroring + * the interpreter's own --format text/html report - day significance, + * each significant transit's narrative, the full Celtic Cross spread + * (each position's card art at the watch's own display width, name, + * description, and meaning), and the guidance paragraph last - see + * reading_state.h for where all of this data comes from. + * + * Card art is loaded lazily as it scrolls into view and freed once it + * scrolls back out - a full-display-width card image is too large to + * keep all ten resident at once on this app's RAM budget (see + * ui_report_window.c's own comment) - so unlike a plain ScrollLayer of + * static content, this needs report_window_push()'s window to actually + * be on-screen to drive that loading; call report_window_reload() (not + * push() again) to refresh already-built content after the day rolls + * over or new birth data is submitted. */ +void report_window_push(void); +void report_window_reload(void); +void report_window_deinit(void); + +#endif diff --git a/watch/src/c/ui_text_window.c b/watch/src/c/ui_text_window.c index 6a5afa1..f38cc95 100644 --- a/watch/src/c/ui_text_window.c +++ b/watch/src/c/ui_text_window.c @@ -2,7 +2,7 @@ #include -#define TITLE_HEIGHT 24 +#define TITLE_HEIGHT 30 typedef struct { char *title; @@ -18,7 +18,7 @@ static void window_load(Window *window) { GRect bounds = layer_get_bounds(window_layer); state->title_layer = text_layer_create(GRect(0, 0, bounds.size.w, TITLE_HEIGHT)); - text_layer_set_font(state->title_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD)); + text_layer_set_font(state->title_layer, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD)); text_layer_set_text(state->title_layer, state->title); text_layer_set_overflow_mode(state->title_layer, GTextOverflowModeTrailingEllipsis); layer_add_child(window_layer, text_layer_get_layer(state->title_layer)); @@ -31,7 +31,7 @@ static void window_load(Window *window) { * measure the real wrapped height - standard Pebble ScrollLayer * pattern (a TextLayer taller than its content just clips nothing). */ state->body_layer = text_layer_create(GRect(0, 0, scroll_bounds.size.w, 2000)); - text_layer_set_font(state->body_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18)); + text_layer_set_font(state->body_layer, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD)); text_layer_set_text(state->body_layer, state->body); text_layer_set_overflow_mode(state->body_layer, GTextOverflowModeWordWrap); diff --git a/watch/src/pkjs/index.js b/watch/src/pkjs/index.js index 09d4264..18c5b7b 100644 --- a/watch/src/pkjs/index.js +++ b/watch/src/pkjs/index.js @@ -1,5 +1,220 @@ -// Skeleton only - the real config webview (birth data/language/ -// notification settings) is built out in a later task, see CLAUDE.md/ -// TaskList. No companion app, no server: this file's only future job is -// showConfiguration/webviewclosed handling for a self-contained `data:` -// URI settings form. +// Self-contained Pebble config page - no server, no companion app. The +// form itself is a `data:` URI built entirely here in pkjs and opened +// via Pebble.openURL(); app_message.c's inbox_received_handler is the +// receiving side this pairs with (see its own comment for the wire +// format - lat/lon/UTC offset cross as scaled int32s since AppMessage +// has no float tuple type). +// +// The webview's own storage can't be relied on to remember values +// between openings (Pebble treats each `data:` URI load as a fresh, +// effectively unique origin), so previously-submitted values are kept +// in *pkjs's* own localStorage (persists across app launches on the +// phone) and baked into the generated HTML each time the page opens. +// +// Plain ES5 throughout (var, string concatenation, no arrow functions/ +// template literals) - the webview runs in whatever WebView the Pebble +// mobile app itself embeds, not a guaranteed-modern engine. + +var STORAGE_KEY = 'deckInADashConfig'; +var LANGUAGES = [ + { code: 'en', label: 'English' }, + { code: 'de', label: 'Deutsch' } +]; + +function loadSavedConfig() { + try { + var raw = window.localStorage.getItem(STORAGE_KEY); + if (raw) return JSON.parse(raw); + } catch (e) { + // Corrupt/inaccessible storage - fall through to defaults below. + } + return {}; +} + +function saveConfig(cfg) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); + } catch (e) { + // Best-effort only - the watch already has the real submitted + // values via AppMessage regardless of whether this cache succeeds. + } +} + +function escapeHtml(value) { + var s = value === null || value === undefined ? '' : String(value); + return s.replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); +} + +function buildLanguageOptions(selected) { + var html = ''; + for (var i = 0; i < LANGUAGES.length; i++) { + var lang = LANGUAGES[i]; + var isSelected = lang.code === selected ? ' selected' : ''; + html += ''; + } + return html; +} + +// Runs *inside* the webview (embedded as a ' + + ''; +} + +Pebble.addEventListener('ready', function () { + console.log('Deck in a Dash: pkjs ready, showConfiguration listener registered'); +}); + +Pebble.addEventListener('showConfiguration', function () { + console.log('Deck in a Dash: showConfiguration fired'); + var html = buildConfigHtml(loadSavedConfig()); + Pebble.openURL('data:text/html;charset=utf-8,' + encodeURIComponent(html)); +}); + +Pebble.addEventListener('webviewclosed', function (e) { + if (!e || !e.response) return; + + var result; + try { + result = JSON.parse(decodeURIComponent(e.response)); + } catch (err) { + return; // Cancelled (no response) or a malformed payload - ignore. + } + + saveConfig({ + birth_date: result.saved_birth_date, + birth_time: result.saved_birth_time, + birth_utc_offset: result.saved_birth_utc_offset, + birth_lat: result.saved_birth_lat, + birth_lon: result.saved_birth_lon, + lang: result.lang, + notify_enabled: result.notify_enabled === 1, + notify_time: result.saved_notify_time + }); + + Pebble.sendAppMessage({ + BIRTH_YEAR: result.birth_year, + BIRTH_MONTH: result.birth_month, + BIRTH_DAY: result.birth_day, + BIRTH_HOUR: result.birth_hour, + BIRTH_MINUTE: result.birth_minute, + BIRTH_UTC_OFFSET_MINUTES: result.birth_utc_offset_minutes, + BIRTH_LAT_MICRODEG: result.birth_lat_microdeg, + BIRTH_LON_MICRODEG: result.birth_lon_microdeg, + LANG: result.lang, + NOTIFY_ENABLED: result.notify_enabled, + NOTIFY_HOUR: result.notify_hour, + NOTIFY_MINUTE: result.notify_minute + }, function () { + console.log('Deck in a Dash: config sent to watch'); + }, function (err) { + console.log('Deck in a Dash: config send failed: ' + JSON.stringify(err)); + }); +});