Introduces watch/, a real Pebble watchapp (built via pebble build) that
build / build (push) Successful in 30s
build / build (push) Successful in 30s
shows the daily tarot/astrology reading on-device: persistence, reading computation, and UI screens, driven by the existing engine/interpreter code linked in unchanged where possible. Required engine-side changes to make that linking work: - A compact low-precision ephemeris (lowprec_ephemeris.c) replacing the vendored ~127KB Astronomy Engine, which doesn't fit the watch's ~64KB app budget. - Hand-rolled sqrt/atan2/sin/cos replacements for that ephemeris and astro.c's Ascendant calculation - Pebble's statically-linked libm hard-faults on real hardware under this app's -fPIE link for all four. - narrative.c/guidance.c refactored from FILE*/fprintf onto snprintf- based buffers, since Pebble's SDK blocks fprintf at compile time.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
#include "app_message.h"
|
||||
|
||||
#include <pebble.h>
|
||||
|
||||
/* AppMessage's Dictionary has no float tuple type, so latitude/
|
||||
* longitude/utc_offset_hours (all doubles in BirthData) cross the wire
|
||||
* as scaled int32s - microdegrees for lat/lon (~0.11m precision, far
|
||||
* finer than this app's astrology needs), minutes for the UTC offset
|
||||
* (covers every real-world offset, including the half/quarter-hour
|
||||
* ones) - see config.c's own persisted representation, which uses the
|
||||
* same scaling so no conversion happens twice. */
|
||||
|
||||
static ConfigUpdatedCallback s_callback;
|
||||
|
||||
static bool read_int(DictionaryIterator *iter, uint32_t key, int32_t *out) {
|
||||
Tuple *t = dict_find(iter, key);
|
||||
if (!t) return false;
|
||||
*out = t->value->int32;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void inbox_received_handler(DictionaryIterator *iter, void *context) {
|
||||
WatchConfig cfg;
|
||||
config_load(&cfg); /* start from existing settings - a field the page
|
||||
* doesn't send (e.g. it only changed the language)
|
||||
* keeps its previous value rather than resetting. */
|
||||
|
||||
int32_t v;
|
||||
bool got_birth = false;
|
||||
if (read_int(iter, MESSAGE_KEY_BIRTH_YEAR, &v)) { cfg.birth.year = v; got_birth = true; }
|
||||
if (read_int(iter, MESSAGE_KEY_BIRTH_MONTH, &v)) { cfg.birth.month = v; got_birth = true; }
|
||||
if (read_int(iter, MESSAGE_KEY_BIRTH_DAY, &v)) { cfg.birth.day = v; got_birth = true; }
|
||||
if (read_int(iter, MESSAGE_KEY_BIRTH_HOUR, &v)) { cfg.birth.hour = v; got_birth = true; }
|
||||
if (read_int(iter, MESSAGE_KEY_BIRTH_MINUTE, &v)) { cfg.birth.minute = v; got_birth = true; }
|
||||
if (read_int(iter, MESSAGE_KEY_BIRTH_UTC_OFFSET_MINUTES, &v)) {
|
||||
cfg.birth.utc_offset_hours = v / 60.0;
|
||||
got_birth = true;
|
||||
}
|
||||
if (read_int(iter, MESSAGE_KEY_BIRTH_LAT_MICRODEG, &v)) {
|
||||
cfg.birth.latitude = v / 1000000.0;
|
||||
got_birth = true;
|
||||
}
|
||||
if (read_int(iter, MESSAGE_KEY_BIRTH_LON_MICRODEG, &v)) {
|
||||
cfg.birth.longitude = v / 1000000.0;
|
||||
got_birth = true;
|
||||
}
|
||||
|
||||
Tuple *lang_tuple = dict_find(iter, MESSAGE_KEY_LANG);
|
||||
if (lang_tuple) {
|
||||
strncpy(cfg.lang, lang_tuple->value->cstring, sizeof(cfg.lang) - 1);
|
||||
cfg.lang[sizeof(cfg.lang) - 1] = '\0';
|
||||
}
|
||||
|
||||
if (read_int(iter, MESSAGE_KEY_NOTIFY_ENABLED, &v)) cfg.notify_enabled = (v != 0);
|
||||
if (read_int(iter, MESSAGE_KEY_NOTIFY_HOUR, &v)) cfg.notify_hour = v;
|
||||
if (read_int(iter, MESSAGE_KEY_NOTIFY_MINUTE, &v)) cfg.notify_minute = v;
|
||||
|
||||
/* Birth data is the one thing that makes a config submission "complete" -
|
||||
* language/notification-only re-submissions (e.g. from a later re-open
|
||||
* of the settings page) must not un-configure an already-set-up watch. */
|
||||
if (got_birth) cfg.configured = true;
|
||||
|
||||
config_save(&cfg);
|
||||
if (s_callback) s_callback(&cfg);
|
||||
}
|
||||
|
||||
static void inbox_dropped_handler(AppMessageResult reason, void *context) {
|
||||
APP_LOG(APP_LOG_LEVEL_ERROR, "AppMessage dropped: reason %d", (int)reason);
|
||||
}
|
||||
|
||||
/* app_message_*_size_maximum() return the largest buffer the platform
|
||||
* *could* hand out (8200 bytes each) - not a size to actually request.
|
||||
* The real payload is ~150-200 bytes; 256 bytes each leaves headroom
|
||||
* without eating into this app's ~12KB heap budget (see watch/README.md). */
|
||||
#define INBOX_SIZE 256
|
||||
#define OUTBOX_SIZE 256
|
||||
|
||||
void app_message_init(ConfigUpdatedCallback on_config_updated) {
|
||||
s_callback = on_config_updated;
|
||||
app_message_register_inbox_received(inbox_received_handler);
|
||||
app_message_register_inbox_dropped(inbox_dropped_handler);
|
||||
app_message_open(INBOX_SIZE, OUTBOX_SIZE);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#ifndef WATCH_APP_MESSAGE_H
|
||||
#define WATCH_APP_MESSAGE_H
|
||||
|
||||
#include "config.h"
|
||||
|
||||
/* Invoked with the freshly-saved config whenever a settings submission
|
||||
* arrives from the config page (src/pkjs/index.js, task #43) - main.c
|
||||
* uses this to recompute the reading and refresh/show the menu. */
|
||||
typedef void (*ConfigUpdatedCallback)(const WatchConfig *cfg);
|
||||
|
||||
/* Registers the AppMessage inbox handler and opens the message buffers.
|
||||
* Call once at startup, before app_event_loop(). Message keys
|
||||
* (BIRTH_YEAR, BIRTH_UTC_OFFSET_MINUTES, LANG, NOTIFY_ENABLED, etc.) are
|
||||
* declared in watch/package.json's "messageKeys" and turn into
|
||||
* MESSAGE_KEY_* constants in the auto-generated message_keys.auto.h -
|
||||
* see app_message.c's own comment on why latitude/longitude/utc offset
|
||||
* travel as scaled integers rather than floats. */
|
||||
void app_message_init(ConfigUpdatedCallback on_config_updated);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,70 @@
|
||||
#include "config.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
/* Persist keys. BirthData's latitude/longitude/utc_offset_hours are
|
||||
* doubles, but Pebble's persist API has no float storage - stored as
|
||||
* scaled int32s instead (microdegrees / minutes), matching how
|
||||
* AppMessage carries the same fields from the config page - see
|
||||
* app_message.c. */
|
||||
enum {
|
||||
PKEY_CONFIGURED = 0,
|
||||
PKEY_BIRTH_YEAR,
|
||||
PKEY_BIRTH_MONTH,
|
||||
PKEY_BIRTH_DAY,
|
||||
PKEY_BIRTH_HOUR,
|
||||
PKEY_BIRTH_MINUTE,
|
||||
PKEY_BIRTH_UTC_OFFSET_MINUTES,
|
||||
PKEY_BIRTH_LAT_MICRODEG,
|
||||
PKEY_BIRTH_LON_MICRODEG,
|
||||
PKEY_LANG,
|
||||
PKEY_NOTIFY_ENABLED,
|
||||
PKEY_NOTIFY_HOUR,
|
||||
PKEY_NOTIFY_MINUTE,
|
||||
};
|
||||
|
||||
void config_load(WatchConfig *out) {
|
||||
memset(out, 0, sizeof *out);
|
||||
|
||||
out->configured = persist_exists(PKEY_CONFIGURED) && persist_read_bool(PKEY_CONFIGURED);
|
||||
if (!out->configured) {
|
||||
strcpy(out->lang, "en");
|
||||
return;
|
||||
}
|
||||
|
||||
out->birth.year = (int)persist_read_int(PKEY_BIRTH_YEAR);
|
||||
out->birth.month = (int)persist_read_int(PKEY_BIRTH_MONTH);
|
||||
out->birth.day = (int)persist_read_int(PKEY_BIRTH_DAY);
|
||||
out->birth.hour = (int)persist_read_int(PKEY_BIRTH_HOUR);
|
||||
out->birth.minute = (int)persist_read_int(PKEY_BIRTH_MINUTE);
|
||||
out->birth.utc_offset_hours = (double)(int32_t)persist_read_int(PKEY_BIRTH_UTC_OFFSET_MINUTES) / 60.0;
|
||||
out->birth.latitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LAT_MICRODEG) / 1000000.0;
|
||||
out->birth.longitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LON_MICRODEG) / 1000000.0;
|
||||
|
||||
if (persist_read_string(PKEY_LANG, out->lang, sizeof out->lang) <= 0) {
|
||||
strcpy(out->lang, "en");
|
||||
}
|
||||
|
||||
out->notify_enabled = persist_read_bool(PKEY_NOTIFY_ENABLED);
|
||||
out->notify_hour = (int)persist_read_int(PKEY_NOTIFY_HOUR);
|
||||
out->notify_minute = (int)persist_read_int(PKEY_NOTIFY_MINUTE);
|
||||
}
|
||||
|
||||
void config_save(const WatchConfig *cfg) {
|
||||
persist_write_bool(PKEY_CONFIGURED, cfg->configured);
|
||||
|
||||
persist_write_int(PKEY_BIRTH_YEAR, cfg->birth.year);
|
||||
persist_write_int(PKEY_BIRTH_MONTH, cfg->birth.month);
|
||||
persist_write_int(PKEY_BIRTH_DAY, cfg->birth.day);
|
||||
persist_write_int(PKEY_BIRTH_HOUR, cfg->birth.hour);
|
||||
persist_write_int(PKEY_BIRTH_MINUTE, cfg->birth.minute);
|
||||
persist_write_int(PKEY_BIRTH_UTC_OFFSET_MINUTES, (int32_t)(cfg->birth.utc_offset_hours * 60.0));
|
||||
persist_write_int(PKEY_BIRTH_LAT_MICRODEG, (int32_t)(cfg->birth.latitude * 1000000.0));
|
||||
persist_write_int(PKEY_BIRTH_LON_MICRODEG, (int32_t)(cfg->birth.longitude * 1000000.0));
|
||||
|
||||
persist_write_string(PKEY_LANG, cfg->lang);
|
||||
|
||||
persist_write_bool(PKEY_NOTIFY_ENABLED, cfg->notify_enabled);
|
||||
persist_write_int(PKEY_NOTIFY_HOUR, cfg->notify_hour);
|
||||
persist_write_int(PKEY_NOTIFY_MINUTE, cfg->notify_minute);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#ifndef WATCH_CONFIG_H
|
||||
#define WATCH_CONFIG_H
|
||||
|
||||
#include <pebble.h>
|
||||
|
||||
#include "../../../engine/src/astro.h"
|
||||
|
||||
/* Everything the watch needs that isn't computed fresh each day: birth
|
||||
* data (the same fields as dist/user.properties), display language, and
|
||||
* daily notification settings. Persisted on-watch via Pebble's own
|
||||
* key-value persist API (see config.c) - no companion app, no server,
|
||||
* matching the rest of this project's architecture. */
|
||||
typedef struct {
|
||||
bool configured; /* false until the config page has been submitted once */
|
||||
BirthData birth;
|
||||
char lang[8]; /* e.g. "en", "de" - matches engine/i18n/<lang>.lang's own code */
|
||||
bool notify_enabled;
|
||||
int notify_hour; /* 0-23, the watch's own local wall-clock time (i.e.
|
||||
* localtime(), not birth.utc_offset_hours - the
|
||||
* user's current timezone, synced from their phone,
|
||||
* not necessarily their birth location's). */
|
||||
int notify_minute; /* 0-59 */
|
||||
} WatchConfig;
|
||||
|
||||
/* Fills `out` with the persisted config, or a safe all-zero/`configured
|
||||
* = false` default if nothing has been saved yet (first run). */
|
||||
void config_load(WatchConfig *out);
|
||||
|
||||
void config_save(const WatchConfig *cfg);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,16 @@
|
||||
#ifndef WATCH_I18N_TABLES_H
|
||||
#define WATCH_I18N_TABLES_H
|
||||
|
||||
/* Points i18n_get() (engine/src/i18n.h) at the compiled-in translation
|
||||
* table for `lang_code`, generated at build time from engine/i18n's
|
||||
* .lang files by watch/scripts/gen_i18n_tables.py into
|
||||
* i18n_tables.auto.c - see that script's own doc comment for the format
|
||||
* and why English is deliberately not one of the compiled tables.
|
||||
* Passing "en", NULL, or any code with no matching .lang file resets to
|
||||
* no table at all, so every i18n_get() call falls through to its
|
||||
* built-in English fallback text - the correct behavior when a user
|
||||
* switches the watch's language setting back to English at runtime,
|
||||
* not just on first load. */
|
||||
void watch_i18n_load(const char *lang_code);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,50 @@
|
||||
#include <pebble.h>
|
||||
|
||||
#include "../../../engine/src/i18n.h"
|
||||
#include "app_message.h"
|
||||
#include "config.h"
|
||||
#include "i18n_tables.h"
|
||||
#include "reading_state.h"
|
||||
#include "ui_menu_window.h"
|
||||
#include "ui_text_window.h"
|
||||
|
||||
static WatchConfig s_config;
|
||||
|
||||
static void show_setup_required(void) {
|
||||
text_window_push(i18n_get("ui.setup_required_title", "Setup Required"),
|
||||
i18n_get("ui.setup_required_body",
|
||||
"Open this app's settings from the Pebble mobile app to add your "
|
||||
"birth details, then reopen Deck in a Dash."));
|
||||
}
|
||||
|
||||
static void on_config_updated(const WatchConfig *cfg) {
|
||||
bool was_configured = s_config.configured;
|
||||
s_config = *cfg;
|
||||
watch_i18n_load(s_config.lang);
|
||||
|
||||
if (!s_config.configured) return;
|
||||
|
||||
reading_state_recompute(&s_config.birth);
|
||||
if (was_configured) {
|
||||
menu_window_reload();
|
||||
} else {
|
||||
menu_window_push();
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
config_load(&s_config);
|
||||
watch_i18n_load(s_config.lang);
|
||||
app_message_init(on_config_updated);
|
||||
|
||||
if (s_config.configured) {
|
||||
reading_state_recompute(&s_config.birth);
|
||||
menu_window_push();
|
||||
} else {
|
||||
show_setup_required();
|
||||
}
|
||||
|
||||
app_event_loop();
|
||||
|
||||
menu_window_deinit();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#include "reading_state.h"
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
static DailyReading s_reading;
|
||||
static DailyInterpretation s_interp;
|
||||
|
||||
void reading_state_recompute(const BirthData *birth) {
|
||||
time_t now = time(NULL);
|
||||
/* Plain gmtime() (not the POSIX-only gmtime_r()), used immediately -
|
||||
* same justification as astro.c's own time_from_unix(). */
|
||||
struct tm *utc = gmtime(&now);
|
||||
|
||||
char seed[16];
|
||||
snprintf(seed, sizeof seed, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
|
||||
|
||||
reading_generate(seed, birth, now, &s_reading);
|
||||
interpret_daily_reading(&s_reading, &s_interp);
|
||||
}
|
||||
|
||||
const DailyReading *reading_state_get_reading(void) {
|
||||
return &s_reading;
|
||||
}
|
||||
|
||||
const DailyInterpretation *reading_state_get_interpretation(void) {
|
||||
return &s_interp;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#ifndef WATCH_READING_STATE_H
|
||||
#define WATCH_READING_STATE_H
|
||||
|
||||
#include "../../../engine/src/reading.h"
|
||||
#include "../../../interpreter/src/significance.h"
|
||||
|
||||
/* Recomputes today's reading (astro + tarot + interpretation) from
|
||||
* `birth`, using the watch's current clock: today's UTC date as the
|
||||
* tarot seed (same convention as dist/run-engine.sh - see CLAUDE.md's
|
||||
* "Build & run") and the current moment for the transit snapshot. Safe
|
||||
* to call again later (e.g. the day rolled over while the app stayed
|
||||
* open) - overwrites the previous result in place. */
|
||||
void reading_state_recompute(const BirthData *birth);
|
||||
|
||||
/* Valid only after at least one reading_state_recompute() call. */
|
||||
const DailyReading *reading_state_get_reading(void);
|
||||
const DailyInterpretation *reading_state_get_interpretation(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,31 @@
|
||||
#include "resource_map.h"
|
||||
|
||||
#include <pebble.h>
|
||||
|
||||
uint32_t card_resource_id(TarotCard card) {
|
||||
switch (card) {
|
||||
case CARD_FOOL: return RESOURCE_ID_IMG_CARD_FOOL;
|
||||
case CARD_MAGICIAN: return RESOURCE_ID_IMG_CARD_MAGICIAN;
|
||||
case CARD_HIGH_PRIESTESS: return RESOURCE_ID_IMG_CARD_HIGH_PRIESTESS;
|
||||
case CARD_EMPRESS: return RESOURCE_ID_IMG_CARD_EMPRESS;
|
||||
case CARD_EMPEROR: return RESOURCE_ID_IMG_CARD_EMPEROR;
|
||||
case CARD_HIEROPHANT: return RESOURCE_ID_IMG_CARD_HIEROPHANT;
|
||||
case CARD_LOVERS: return RESOURCE_ID_IMG_CARD_LOVERS;
|
||||
case CARD_CHARIOT: return RESOURCE_ID_IMG_CARD_CHARIOT;
|
||||
case CARD_STRENGTH: return RESOURCE_ID_IMG_CARD_STRENGTH;
|
||||
case CARD_HERMIT: return RESOURCE_ID_IMG_CARD_HERMIT;
|
||||
case CARD_WHEEL_OF_FORTUNE: return RESOURCE_ID_IMG_CARD_WHEEL_OF_FORTUNE;
|
||||
case CARD_JUSTICE: return RESOURCE_ID_IMG_CARD_JUSTICE;
|
||||
case CARD_HANGED_MAN: return RESOURCE_ID_IMG_CARD_HANGED_MAN;
|
||||
case CARD_DEATH: return RESOURCE_ID_IMG_CARD_DEATH;
|
||||
case CARD_TEMPERANCE: return RESOURCE_ID_IMG_CARD_TEMPERANCE;
|
||||
case CARD_DEVIL: return RESOURCE_ID_IMG_CARD_DEVIL;
|
||||
case CARD_TOWER: return RESOURCE_ID_IMG_CARD_TOWER;
|
||||
case CARD_STAR: return RESOURCE_ID_IMG_CARD_STAR;
|
||||
case CARD_MOON: return RESOURCE_ID_IMG_CARD_MOON;
|
||||
case CARD_SUN: return RESOURCE_ID_IMG_CARD_SUN;
|
||||
case CARD_JUDGEMENT: return RESOURCE_ID_IMG_CARD_JUDGEMENT;
|
||||
case CARD_WORLD: return RESOURCE_ID_IMG_CARD_WORLD;
|
||||
}
|
||||
return RESOURCE_ID_IMG_CARD_FOOL; /* unreachable for a valid TarotCard */
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#ifndef WATCH_RESOURCE_MAP_H
|
||||
#define WATCH_RESOURCE_MAP_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include "../../../engine/src/tarot.h"
|
||||
|
||||
/* Maps a TarotCard to its compiled-in bitmap resource ID. Each of
|
||||
* watch/package.json's "resources.media" entries is named
|
||||
* IMG_CARD_<SLUG UPPERCASE>, matching engine/src/tarot_data.c's own
|
||||
* k_card_slug table (see watch/scripts/gen_card_images.py, which
|
||||
* generates the underlying PNGs with the same slugs) - Pebble's build
|
||||
* turns each into a RESOURCE_ID_IMG_CARD_<SLUG> macro in the
|
||||
* auto-generated resource_ids.auto.h, which this function is the one
|
||||
* place in the app that switches over. */
|
||||
uint32_t card_resource_id(TarotCard card);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,78 @@
|
||||
#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);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#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
|
||||
@@ -0,0 +1,191 @@
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#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,74 @@
|
||||
#include "ui_text_window.h"
|
||||
|
||||
#include <pebble.h>
|
||||
|
||||
#define TITLE_HEIGHT 24
|
||||
|
||||
typedef struct {
|
||||
char *title;
|
||||
char *body;
|
||||
TextLayer *title_layer;
|
||||
ScrollLayer *scroll_layer;
|
||||
TextLayer *body_layer;
|
||||
} TextWindowState;
|
||||
|
||||
static void window_load(Window *window) {
|
||||
TextWindowState *state = window_get_user_data(window);
|
||||
Layer *window_layer = window_get_root_layer(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_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));
|
||||
|
||||
GRect scroll_bounds = GRect(0, TITLE_HEIGHT, bounds.size.w, bounds.size.h - TITLE_HEIGHT);
|
||||
state->scroll_layer = scroll_layer_create(scroll_bounds);
|
||||
scroll_layer_set_click_config_onto_window(state->scroll_layer, window);
|
||||
|
||||
/* Tall placeholder height so text_layer_get_content_size() below can
|
||||
* 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_text(state->body_layer, state->body);
|
||||
text_layer_set_overflow_mode(state->body_layer, GTextOverflowModeWordWrap);
|
||||
|
||||
GSize content_size = text_layer_get_content_size(state->body_layer);
|
||||
content_size.w = scroll_bounds.size.w;
|
||||
content_size.h += 4;
|
||||
text_layer_set_size(state->body_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->body_layer));
|
||||
layer_add_child(window_layer, scroll_layer_get_layer(state->scroll_layer));
|
||||
}
|
||||
|
||||
static void window_unload(Window *window) {
|
||||
TextWindowState *state = window_get_user_data(window);
|
||||
text_layer_destroy(state->title_layer);
|
||||
text_layer_destroy(state->body_layer);
|
||||
scroll_layer_destroy(state->scroll_layer);
|
||||
free(state->title);
|
||||
free(state->body);
|
||||
free(state);
|
||||
window_destroy(window);
|
||||
}
|
||||
|
||||
void text_window_push(const char *title, const char *body) {
|
||||
TextWindowState *state = malloc(sizeof(TextWindowState));
|
||||
memset(state, 0, sizeof *state);
|
||||
state->title = malloc(strlen(title) + 1);
|
||||
strcpy(state->title, title);
|
||||
state->body = malloc(strlen(body) + 1);
|
||||
strcpy(state->body, 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);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#ifndef WATCH_UI_TEXT_WINDOW_H
|
||||
#define WATCH_UI_TEXT_WINDOW_H
|
||||
|
||||
/* Pushes a scrollable full-screen text window: `title` as a bold header,
|
||||
* `body` below it, word-wrapped and scrollable with the up/down buttons.
|
||||
* Used for every screen that's just prose (Summary/Guidance, a single
|
||||
* transit's narrative) - the Celtic Cross card screens use
|
||||
* ui_card_window.h instead, which also shows the card's image.
|
||||
*
|
||||
* Copies both strings internally (into the pushed window's own heap
|
||||
* state, freed when it's popped), so the caller's buffer doesn't need to
|
||||
* outlive the call - every caller so far builds `title`/`body` in a
|
||||
* short-lived local buffer right before pushing. */
|
||||
void text_window_push(const char *title, const char *body);
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user