Replace the watch app's menu UI with a single scrollable report page, add the config page
build / build (push) Successful in 18s
build / build (push) Successful in 18s
Report screen (ui_report_window.c, new): replaces the MenuLayer +
per-item drill-down windows (ui_menu_window.c/h, ui_card_window.c/h,
removed) with one continuously scrollable page mirroring the
interpreter's own text/HTML report - day significance, each transit's
narrative, the full Celtic Cross spread, and guidance last. Bigger/
bolder fonts throughout. Card images are a fixed 72px wide: a full-
display-width version was tried first but reliably crashes the real
PNG decoder ("PNG memory allocation failed") on actual hardware,
independent of the app's own heap or lazy-loading - 72px is the
hardware-verified ceiling.
Config page (pkjs/index.js): a self-contained `data:` URI settings form
for birth data/language/notification, submitting via AppMessage to the
existing app_message.c receiver. package.json needed
"capabilities": ["configurable"] for the Pebble mobile app to expose a
Settings entry for the sideloaded app at all - app_message.c's own
receiving logic was already correct. Added config_log() (config.c) to
confirm on-device, via APP_LOG, exactly which values a shown reading is
based on - both right after a config submission and at every app
launch.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
Binary file not shown.
+116
-23
@@ -2,7 +2,9 @@
|
|||||||
"name": "deck_in_a_dash_watch",
|
"name": "deck_in_a_dash_watch",
|
||||||
"author": "Matthias Ladkau",
|
"author": "Matthias Ladkau",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"keywords": ["pebble-app"],
|
"keywords": [
|
||||||
|
"pebble-app"
|
||||||
|
],
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"pebble": {
|
"pebble": {
|
||||||
@@ -10,6 +12,9 @@
|
|||||||
"uuid": "7f47ed93-f44e-4c1a-9998-7edcfa61218e",
|
"uuid": "7f47ed93-f44e-4c1a-9998-7edcfa61218e",
|
||||||
"sdkVersion": "3",
|
"sdkVersion": "3",
|
||||||
"enableMultiJS": true,
|
"enableMultiJS": true,
|
||||||
|
"capabilities": [
|
||||||
|
"configurable"
|
||||||
|
],
|
||||||
"targetPlatforms": [
|
"targetPlatforms": [
|
||||||
"basalt",
|
"basalt",
|
||||||
"chalk",
|
"chalk",
|
||||||
@@ -37,28 +42,116 @@
|
|||||||
],
|
],
|
||||||
"resources": {
|
"resources": {
|
||||||
"media": [
|
"media": [
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_FOOL", "file": "img/fool.png" },
|
{
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_MAGICIAN", "file": "img/magician.png" },
|
"type": "bitmap",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_HIGH_PRIESTESS", "file": "img/high_priestess.png" },
|
"name": "IMG_CARD_FOOL",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_EMPRESS", "file": "img/empress.png" },
|
"file": "img/fool.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",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_CHARIOT", "file": "img/chariot.png" },
|
"name": "IMG_CARD_MAGICIAN",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_STRENGTH", "file": "img/strength.png" },
|
"file": "img/magician.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",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_HANGED_MAN", "file": "img/hanged_man.png" },
|
"name": "IMG_CARD_HIGH_PRIESTESS",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_DEATH", "file": "img/death.png" },
|
"file": "img/high_priestess.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",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_STAR", "file": "img/star.png" },
|
"name": "IMG_CARD_EMPRESS",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_MOON", "file": "img/moon.png" },
|
"file": "img/empress.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_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"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,20 @@
|
|||||||
|
|
||||||
The 22 Major Arcana JPEGs in res/img/ are ~1MB/~1086x1810px each -
|
The 22 Major Arcana JPEGs in res/img/ are ~1MB/~1086x1810px each -
|
||||||
suitable for the desktop HTML reading (an <img> tag pointed at the file
|
suitable for the desktop HTML reading (an <img> tag pointed at the file
|
||||||
as-is) but far too large for a Pebble app's own resource budget (256KB
|
as-is) but far too large for a Pebble app's own resource budget. This
|
||||||
total on basalt-class platforms, shared with every other resource this
|
script resizes each to CARD_WIDTH px wide (preserving aspect ratio) and
|
||||||
app ships). This script resizes each to CARD_WIDTH px wide (preserving
|
writes a PNG per card, named after tarot_data.c's own k_card_slug table
|
||||||
aspect ratio) and writes a PNG per card, named after tarot_data.c's own
|
so watch/package.json's "resources.media" entries (IMG_CARD_<SLUG
|
||||||
k_card_slug table so watch/package.json's "resources.media" entries
|
UPPERCASE>, file img/<slug>.png) line up without a separate mapping
|
||||||
(IMG_CARD_<SLUG UPPERCASE>, file img/<slug>.png) line up without a
|
table anywhere.
|
||||||
separate mapping table anywhere.
|
|
||||||
|
|
||||||
CARD_WIDTH=72 (-> 72x120) was picked empirically: a real `pebble build`
|
CARD_WIDTH=72 (-> 72x120) is a hardware-verified ceiling, not a design
|
||||||
of two sample cards at this size measured ~5KB/card on color platforms
|
choice: full-display-width images (144-260px depending on platform) were
|
||||||
(basalt/chalk/emery/gabbro) and ~2.9KB/card on the black-and-white
|
tried first and reliably crash real hardware with "PNG memory allocation
|
||||||
diorite/flint - so all 22 cards together cost roughly 111KB/64KB
|
failed" - a runtime PNG-decoder memory constraint independent of the
|
||||||
respectively, comfortably inside the 256KB resource budget with room
|
app's own heap budget (lazy-loading only one image at a time didn't help
|
||||||
left for other future resources (fonts, icons), while still rendering
|
either). 72px is the size the original per-card screens used
|
||||||
at close to a full basalt-class screen's height (120px of 168px).
|
successfully all session before that experiment.
|
||||||
|
|
||||||
Generated output, like i18n_tables.auto.c - not committed (see
|
Generated output, like i18n_tables.auto.c - not committed (see
|
||||||
.gitignore), regenerate by running this script directly or via the root
|
.gitignore), regenerate by running this script directly or via the root
|
||||||
|
|||||||
@@ -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. */
|
* of the settings page) must not un-configure an already-set-up watch. */
|
||||||
if (got_birth) cfg.configured = true;
|
if (got_birth) cfg.configured = true;
|
||||||
|
|
||||||
|
config_log(&cfg); /* confirms what was actually received/parsed */
|
||||||
config_save(&cfg);
|
config_save(&cfg);
|
||||||
if (s_callback) s_callback(&cfg);
|
if (s_callback) s_callback(&cfg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,3 +68,19 @@ void config_save(const WatchConfig *cfg) {
|
|||||||
persist_write_int(PKEY_NOTIFY_HOUR, cfg->notify_hour);
|
persist_write_int(PKEY_NOTIFY_HOUR, cfg->notify_hour);
|
||||||
persist_write_int(PKEY_NOTIFY_MINUTE, cfg->notify_minute);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,4 +28,10 @@ void config_load(WatchConfig *out);
|
|||||||
|
|
||||||
void config_save(const WatchConfig *cfg);
|
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
|
#endif
|
||||||
|
|||||||
+8
-5
@@ -5,7 +5,7 @@
|
|||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "i18n_tables.h"
|
#include "i18n_tables.h"
|
||||||
#include "reading_state.h"
|
#include "reading_state.h"
|
||||||
#include "ui_menu_window.h"
|
#include "ui_report_window.h"
|
||||||
#include "ui_text_window.h"
|
#include "ui_text_window.h"
|
||||||
|
|
||||||
static WatchConfig s_config;
|
static WatchConfig s_config;
|
||||||
@@ -24,11 +24,12 @@ static void on_config_updated(const WatchConfig *cfg) {
|
|||||||
|
|
||||||
if (!s_config.configured) return;
|
if (!s_config.configured) return;
|
||||||
|
|
||||||
|
config_log(&s_config); /* the values this newly-recomputed reading is based on */
|
||||||
reading_state_recompute(&s_config.birth);
|
reading_state_recompute(&s_config.birth);
|
||||||
if (was_configured) {
|
if (was_configured) {
|
||||||
menu_window_reload();
|
report_window_reload();
|
||||||
} else {
|
} else {
|
||||||
menu_window_push();
|
report_window_push();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,14 +38,16 @@ int main(void) {
|
|||||||
watch_i18n_load(s_config.lang);
|
watch_i18n_load(s_config.lang);
|
||||||
app_message_init(on_config_updated);
|
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) {
|
if (s_config.configured) {
|
||||||
reading_state_recompute(&s_config.birth);
|
reading_state_recompute(&s_config.birth);
|
||||||
menu_window_push();
|
report_window_push();
|
||||||
} else {
|
} else {
|
||||||
show_setup_required();
|
show_setup_required();
|
||||||
}
|
}
|
||||||
|
|
||||||
app_event_loop();
|
app_event_loop();
|
||||||
|
|
||||||
menu_window_deinit();
|
report_window_deinit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,3 +29,7 @@ uint32_t card_resource_id(TarotCard card) {
|
|||||||
}
|
}
|
||||||
return RESOURCE_ID_IMG_CARD_FOOL; /* unreachable for a valid TarotCard */
|
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 */
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,4 +15,10 @@
|
|||||||
* place in the app that switches over. */
|
* place in the app that switches over. */
|
||||||
uint32_t card_resource_id(TarotCard card);
|
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
|
#endif
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
#include "ui_card_window.h"
|
|
||||||
|
|
||||||
#include <pebble.h>
|
|
||||||
|
|
||||||
#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);
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
#include "ui_menu_window.h"
|
|
||||||
|
|
||||||
#include <pebble.h>
|
|
||||||
|
|
||||||
#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;
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
#include "ui_report_window.h"
|
||||||
|
|
||||||
|
#include <pebble.h>
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
#include <pebble.h>
|
#include <pebble.h>
|
||||||
|
|
||||||
#define TITLE_HEIGHT 24
|
#define TITLE_HEIGHT 30
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char *title;
|
char *title;
|
||||||
@@ -18,7 +18,7 @@ static void window_load(Window *window) {
|
|||||||
GRect bounds = layer_get_bounds(window_layer);
|
GRect bounds = layer_get_bounds(window_layer);
|
||||||
|
|
||||||
state->title_layer = text_layer_create(GRect(0, 0, bounds.size.w, TITLE_HEIGHT));
|
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_text(state->title_layer, state->title);
|
||||||
text_layer_set_overflow_mode(state->title_layer, GTextOverflowModeTrailingEllipsis);
|
text_layer_set_overflow_mode(state->title_layer, GTextOverflowModeTrailingEllipsis);
|
||||||
layer_add_child(window_layer, text_layer_get_layer(state->title_layer));
|
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
|
* measure the real wrapped height - standard Pebble ScrollLayer
|
||||||
* pattern (a TextLayer taller than its content just clips nothing). */
|
* pattern (a TextLayer taller than its content just clips nothing). */
|
||||||
state->body_layer = text_layer_create(GRect(0, 0, scroll_bounds.size.w, 2000));
|
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_text(state->body_layer, state->body);
|
||||||
text_layer_set_overflow_mode(state->body_layer, GTextOverflowModeWordWrap);
|
text_layer_set_overflow_mode(state->body_layer, GTextOverflowModeWordWrap);
|
||||||
|
|
||||||
|
|||||||
+220
-5
@@ -1,5 +1,220 @@
|
|||||||
// Skeleton only - the real config webview (birth data/language/
|
// Self-contained Pebble config page - no server, no companion app. The
|
||||||
// notification settings) is built out in a later task, see CLAUDE.md/
|
// form itself is a `data:` URI built entirely here in pkjs and opened
|
||||||
// TaskList. No companion app, no server: this file's only future job is
|
// via Pebble.openURL(); app_message.c's inbox_received_handler is the
|
||||||
// showConfiguration/webviewclosed handling for a self-contained `data:`
|
// receiving side this pairs with (see its own comment for the wire
|
||||||
// URI settings form.
|
// 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 += '<option value="' + lang.code + '"' + isSelected + '>' + lang.label + '</option>';
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs *inside* the webview (embedded as a <script> string below), not
|
||||||
|
// in pkjs's own context - reads the form, validates it, and hands the
|
||||||
|
// result back to pkjs via the standard Pebble config-page convention:
|
||||||
|
// navigating to pebblejs://close#<urlencoded JSON>, which fires pkjs's
|
||||||
|
// own 'webviewclosed' event with that payload as e.response.
|
||||||
|
var FORM_SCRIPT =
|
||||||
|
'document.getElementById("cfg").addEventListener("submit", function (ev) {' +
|
||||||
|
' ev.preventDefault();' +
|
||||||
|
' var errorEl = document.getElementById("error");' +
|
||||||
|
' errorEl.style.display = "none";' +
|
||||||
|
' var birthDate = document.getElementById("birth_date").value;' +
|
||||||
|
' var birthTime = document.getElementById("birth_time").value;' +
|
||||||
|
' var utcOffset = parseFloat(document.getElementById("birth_utc_offset").value);' +
|
||||||
|
' var lat = parseFloat(document.getElementById("birth_lat").value);' +
|
||||||
|
' var lon = parseFloat(document.getElementById("birth_lon").value);' +
|
||||||
|
' var lang = document.getElementById("lang").value;' +
|
||||||
|
' var notifyEnabled = document.getElementById("notify_enabled").checked;' +
|
||||||
|
' var notifyTime = document.getElementById("notify_time").value || "08:00";' +
|
||||||
|
' if (!birthDate || !birthTime || isNaN(utcOffset) || isNaN(lat) || isNaN(lon)) {' +
|
||||||
|
' errorEl.textContent = "Please fill in all birth details.";' +
|
||||||
|
' errorEl.style.display = "block";' +
|
||||||
|
' return;' +
|
||||||
|
' }' +
|
||||||
|
' if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {' +
|
||||||
|
' errorEl.textContent = "Latitude must be -90..90, longitude -180..180.";' +
|
||||||
|
' errorEl.style.display = "block";' +
|
||||||
|
' return;' +
|
||||||
|
' }' +
|
||||||
|
' var dateParts = birthDate.split("-");' +
|
||||||
|
' var timeParts = birthTime.split(":");' +
|
||||||
|
' var notifyParts = notifyTime.split(":");' +
|
||||||
|
' var result = {' +
|
||||||
|
' birth_year: parseInt(dateParts[0], 10),' +
|
||||||
|
' birth_month: parseInt(dateParts[1], 10),' +
|
||||||
|
' birth_day: parseInt(dateParts[2], 10),' +
|
||||||
|
' birth_hour: parseInt(timeParts[0], 10),' +
|
||||||
|
' birth_minute: parseInt(timeParts[1], 10),' +
|
||||||
|
' birth_utc_offset_minutes: Math.round(utcOffset * 60),' +
|
||||||
|
' birth_lat_microdeg: Math.round(lat * 1000000),' +
|
||||||
|
' birth_lon_microdeg: Math.round(lon * 1000000),' +
|
||||||
|
' lang: lang,' +
|
||||||
|
' notify_enabled: notifyEnabled ? 1 : 0,' +
|
||||||
|
' notify_hour: parseInt(notifyParts[0], 10),' +
|
||||||
|
' notify_minute: parseInt(notifyParts[1], 10),' +
|
||||||
|
' saved_birth_date: birthDate,' +
|
||||||
|
' saved_birth_time: birthTime,' +
|
||||||
|
' saved_birth_utc_offset: utcOffset,' +
|
||||||
|
' saved_birth_lat: lat,' +
|
||||||
|
' saved_birth_lon: lon,' +
|
||||||
|
' saved_notify_time: notifyTime' +
|
||||||
|
' };' +
|
||||||
|
' document.location = "pebblejs://close#" + encodeURIComponent(JSON.stringify(result));' +
|
||||||
|
'});';
|
||||||
|
|
||||||
|
function buildConfigHtml(saved) {
|
||||||
|
var lang = saved.lang || 'en';
|
||||||
|
var notifyChecked = saved.notify_enabled ? ' checked' : '';
|
||||||
|
return '<!doctype html>' +
|
||||||
|
'<html><head><meta charset="utf-8">' +
|
||||||
|
'<meta name="viewport" content="width=device-width, initial-scale=1">' +
|
||||||
|
'<title>Deck in a Dash Settings</title>' +
|
||||||
|
'<style>' +
|
||||||
|
'body{font-family:sans-serif;margin:0;padding:16px;background:#fff;color:#222;}' +
|
||||||
|
'h1{font-size:20px;}' +
|
||||||
|
'h2{font-size:15px;margin-top:24px;border-bottom:1px solid #ccc;padding-bottom:4px;}' +
|
||||||
|
'label{display:block;margin-top:12px;font-size:13px;color:#555;}' +
|
||||||
|
'input,select{width:100%;box-sizing:border-box;padding:8px;font-size:16px;margin-top:4px;' +
|
||||||
|
'border:1px solid #ccc;border-radius:4px;}' +
|
||||||
|
'.row{display:flex;gap:8px;}' +
|
||||||
|
'.row>div{flex:1;}' +
|
||||||
|
'.checkbox-row{display:flex;align-items:center;margin-top:16px;}' +
|
||||||
|
'.checkbox-row input{width:auto;margin-right:8px;}' +
|
||||||
|
'button{width:100%;margin-top:24px;padding:12px;font-size:16px;background:#5a3fc0;' +
|
||||||
|
'color:#fff;border:none;border-radius:4px;}' +
|
||||||
|
'.error{color:#c00;font-size:13px;margin-top:8px;display:none;}' +
|
||||||
|
'</style></head><body>' +
|
||||||
|
'<h1>Deck in a Dash</h1>' +
|
||||||
|
'<form id="cfg">' +
|
||||||
|
'<h2>Birth Details</h2>' +
|
||||||
|
'<label>Birth date</label>' +
|
||||||
|
'<input type="date" id="birth_date" value="' + escapeHtml(saved.birth_date) + '" required>' +
|
||||||
|
'<label>Birth time (24h, local to birth place)</label>' +
|
||||||
|
'<input type="time" id="birth_time" value="' + escapeHtml(saved.birth_time) + '" required>' +
|
||||||
|
'<label>UTC offset at birth (hours, e.g. 2 for CEST)</label>' +
|
||||||
|
'<input type="number" id="birth_utc_offset" step="0.25" min="-14" max="14" value="' +
|
||||||
|
escapeHtml(saved.birth_utc_offset) + '" required>' +
|
||||||
|
'<div class="row">' +
|
||||||
|
'<div><label>Latitude (°, north +)</label>' +
|
||||||
|
'<input type="number" id="birth_lat" step="0.0001" min="-90" max="90" value="' +
|
||||||
|
escapeHtml(saved.birth_lat) + '" required></div>' +
|
||||||
|
'<div><label>Longitude (°, east +)</label>' +
|
||||||
|
'<input type="number" id="birth_lon" step="0.0001" min="-180" max="180" value="' +
|
||||||
|
escapeHtml(saved.birth_lon) + '" required></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<h2>Display</h2>' +
|
||||||
|
'<label>Language</label>' +
|
||||||
|
'<select id="lang">' + buildLanguageOptions(lang) + '</select>' +
|
||||||
|
'<h2>Daily Notification</h2>' +
|
||||||
|
'<div class="checkbox-row">' +
|
||||||
|
'<input type="checkbox" id="notify_enabled"' + notifyChecked + '>' +
|
||||||
|
'<label style="margin:0;">Notify me daily</label>' +
|
||||||
|
'</div>' +
|
||||||
|
'<label>Notification time</label>' +
|
||||||
|
'<input type="time" id="notify_time" value="' + escapeHtml(saved.notify_time || '08:00') + '">' +
|
||||||
|
'<div class="error" id="error"></div>' +
|
||||||
|
'<button type="submit">Save</button>' +
|
||||||
|
'</form>' +
|
||||||
|
'<script>' + FORM_SCRIPT + '</script>' +
|
||||||
|
'</body></html>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user