feat(BibleVerse): immersive offline bible app - ESP32 color screen

- Single verse fullscreen, auto-advances every minute, tap toggles chrome
- Offline WEB bible split per-book: books.json + 66 bin/idx pairs (~4.1MB)
  - bin = NUL-terminated UTF-8 concatenation, idx = {offset,cnum,vnum}
  - RAM tiny: loads one book at a time, biggest Psalms ~215KB vs 3.8MB monolith
- Adaptive text scaling by length: <40 ultra-large (Jesus wept), <100 large,
  100-200 default, 200-350 small, 350+ compact (Esther 8:9 492c)
- Persistence via user_data_path: progress.txt + favorites.txt (like BookPlayer)
- LVGL fixes: use lvgl_get_text_font(FONT_SIZE_*) - direct montserrat not exported,
  no gradients (bg_grad_color not exported), LV_OPA_95 -> OPA_COVER, format-truncation werror silenced
- Deploys to 192.168.68.112 via PUT /api/apps/install (dashboard port 80, dev 6666 closed)

Tested: build esp32s3 local-sdk 0 missing symbols, 4.2MB .app, screenshot Gen 2:10

Refs: tools/convert_bible.py regenerates assets from WEB source
This commit is contained in:
Adolfo Reyna
2026-07-11 17:08:22 -04:00
parent 12e99f896a
commit e9c396df66
139 changed files with 1566 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(
SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK
)
# Relax format-truncation: buffers sized generously, warnings are noise here
# and other apps (BookPlayer etc.) use same pattern.
target_compile_options(${COMPONENT_LIB} PRIVATE
-Wno-format-truncation
-Wno-unused-but-set-variable
)
+930
View File
@@ -0,0 +1,930 @@
#include <tt_app.h>
#include <tt_lvgl_toolbar.h>
#include <tt_app_alertdialog.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/stat.h>
#include <dirent.h>
#include "freertos/FreeRTOS.h"
#include "esp_log.h"
#include "tactility/lvgl_fonts.h"
#define TAG "BibleVerse"
#define AUTO_ADV_MS 60000 // 1 minute
// Per-book binary format (produced by split_bible.py):
// <bnum>.bin : concatenated NUL-terminated UTF8 verse strings
// <bnum>.idx : header 8 bytes: magic 'BIBK' 4s, version u16, count u32
// then per entry: offset u32, cnum u16, vnum u16 (8 bytes)
// books.json : array of {bnumber, bname, verses, chapters}
#define MAX_BIBLE_BOOKS 66
#define MAX_BOOK_NAME 40
#define MAX_VERSE_TEXT 2048
#define MAX_PATH 320
typedef struct {
uint32_t offset;
uint16_t cnum;
uint16_t vnum;
} VerseIndex;
typedef struct {
int bnumber;
char bname[MAX_BOOK_NAME];
int verse_count;
int chapter_count;
} BookInfo;
typedef struct {
// App
AppHandle app;
// Bible metadata
BookInfo books[MAX_BIBLE_BOOKS];
int book_count;
int total_verses;
// Current position (global index 0..total-1 AND book/chap/verse breakdown)
int cur_global; // 0-based global verse index
int cur_book_idx; // 0..book_count-1 index into books[]
int cur_verse_in_book; // 0..book.verse_count-1
// Loaded book data (only one book at a time)
uint8_t* book_bin; // malloc'd content of XX.bin
size_t book_bin_size;
VerseIndex* book_idx; // malloc'd parsed index
int book_idx_count;
int loaded_bnumber; // which bnumber is loaded, -1 = none
// Settings / favorites
char progress_path[MAX_PATH];
char favorites_path[MAX_PATH];
char books_json_path[MAX_PATH];
char bible_dir[MAX_PATH]; // assets/bible/ path prefix
// SD fallback paths
char sd_bible_dir[MAX_PATH];
bool use_sd_bible;
// Highlight
bool is_highlighted;
// UI
lv_obj_t* root;
lv_obj_t* verse_wrapper; // full screen, clickable to toggle chrome
lv_obj_t* lbl_verse;
lv_obj_t* lbl_ref; // small ref line below verse
lv_obj_t* header_bar;
lv_obj_t* ctrl_bar;
lv_obj_t* lbl_position;
lv_obj_t* lbl_book_title;
lv_obj_t* btn_prev;
lv_obj_t* btn_next;
lv_obj_t* btn_play_pause;
lv_obj_t* btn_highlight;
lv_obj_t* btn_close;
// Timer
lv_timer_t* auto_timer;
bool auto_paused;
bool ui_visible; // chrome visible?
} AppCtx;
static AppCtx g_ctx;
/* ── Forward ── */
static bool load_books_manifest(AppCtx* ctx);
static bool load_book(AppCtx* ctx, int bnumber);
static void unload_book(AppCtx* ctx);
static void show_current_verse(AppCtx* ctx);
static void advance_verse(AppCtx* ctx, int delta);
static void save_progress(AppCtx* ctx);
static bool load_progress(AppCtx* ctx);
static void toggle_ui(AppCtx* ctx);
static void set_chrome_visible(AppCtx* ctx, bool visible);
static bool is_favorite(AppCtx* ctx, int global_idx);
static void toggle_favorite(AppCtx* ctx);
static void ensure_user_dir(AppCtx* ctx);
/* ── Utilities ── */
static void ensure_user_dir(AppCtx* ctx) {
// mkdir -p for progress dir: parent of progress_path
char dir[MAX_PATH];
strncpy(dir, ctx->progress_path, sizeof(dir)-1);
dir[sizeof(dir)-1]='\0';
char* last = strrchr(dir, '/');
if (last) {
*last='\0';
// create intermediate
char tmp[MAX_PATH];
size_t len = strlen(dir);
for (size_t i=1;i<=len;i++) {
if (dir[i]=='/' || dir[i]=='\0') {
char c=dir[i];
dir[i]='\0';
strncpy(tmp, dir, sizeof(tmp)-1);
tmp[sizeof(tmp)-1]='\0';
mkdir(tmp, 0755);
dir[i]=c;
}
}
}
}
static bool file_exists(const char* path) {
struct stat st;
return stat(path, &st)==0;
}
/* ── Bible asset discovery: try assets/bible then SD fallback ── */
static void resolve_bible_paths(AppCtx* ctx) {
// First try app assets: tt_app_get_assets_child_path("bible")
char assets_path[MAX_PATH]; size_t sz=sizeof(assets_path);
tt_app_get_assets_path(ctx->app, assets_path, &sz);
// assets_path already is the assets folder itself
snprintf(ctx->bible_dir, sizeof(ctx->bible_dir), "%s/bible", assets_path);
snprintf(ctx->books_json_path, sizeof(ctx->books_json_path), "%s/books.json", ctx->bible_dir);
// SD fallback: /sdcard/bibles or /sdcard/bible
ctx->use_sd_bible = false;
const char* candidates[] = {"/sdcard/bibles", "/sdcard/bible", "/data/bibles", NULL};
for (int i=0;candidates[i];i++) {
char cand_books[MAX_PATH];
snprintf(cand_books, sizeof(cand_books), "%s/books.json", candidates[i]);
if (file_exists(cand_books)) {
strncpy(ctx->sd_bible_dir, candidates[i], sizeof(ctx->sd_bible_dir)-1);
// prefer SD if assets not found
if (!file_exists(ctx->books_json_path)) {
strncpy(ctx->bible_dir, candidates[i], sizeof(ctx->bible_dir)-1);
strncpy(ctx->books_json_path, cand_books, sizeof(ctx->books_json_path)-1);
ctx->use_sd_bible = true;
}
ESP_LOGI(TAG, "Found SD bible at %s (assets bible exists=%d)", candidates[i], file_exists(assets_path));
break;
}
// also check if XX.bin exists without manifest - if so accept
if (!ctx->use_sd_bible) {
char sample[MAX_PATH];
snprintf(sample, sizeof(sample), "%s/01.bin", candidates[i]);
if (file_exists(sample)) {
// synthesize if no json - will still attempt scan
strncpy(ctx->sd_bible_dir, candidates[i], sizeof(ctx->sd_bible_dir)-1);
if (!file_exists(ctx->books_json_path)) {
strncpy(ctx->bible_dir, candidates[i], sizeof(ctx->bible_dir)-1);
snprintf(ctx->books_json_path, sizeof(ctx->books_json_path), "%s/books.json", ctx->bible_dir);
ctx->use_sd_bible = true;
}
break;
}
}
}
ESP_LOGI(TAG, "Bible dir resolved: %s use_sd=%d", ctx->bible_dir, ctx->use_sd_bible);
}
/* ── Simple JSON parse for books.json (no cJSON to keep deps low) ── */
static bool parse_books_json(AppCtx* ctx, const char* json_str) {
// Extremely simple: look for "bnumber": N and "bname": "...", "verses": N, "chapters": N
// We'll do a state scan: search for { ... } objects inside "books": [
const char* p = strstr(json_str, "\"books\"");
if (!p) return false;
p = strchr(p, '[');
if (!p) return false;
p++;
ctx->book_count = 0;
while (ctx->book_count < MAX_BIBLE_BOOKS) {
const char* obj_start = strchr(p, '{');
if (!obj_start) break;
const char* obj_end = strchr(obj_start, '}');
if (!obj_end) break;
// parse fields within obj_start..obj_end
char temp[512];
size_t len = (size_t)(obj_end - obj_start + 1);
if (len >= sizeof(temp)) len = sizeof(temp)-1;
memcpy(temp, obj_start, len);
temp[len]='\0';
int bnum=0, verses=0, chapters=0;
char bname[MAX_BOOK_NAME]={0};
const char* pn = strstr(temp, "\"bnumber\"");
if (pn) sscanf(pn, "\"bnumber\"%*[: ]%d", &bnum);
const char* pb = strstr(temp, "\"bname\"");
if (pb) {
const char* colon = strchr(pb, ':');
if (colon) {
const char* q1 = strchr(colon, '\"');
if (q1) {
q1++;
const char* q2 = strchr(q1, '\"');
if (q2 && (q2-q1)< (int)sizeof(bname)-1) {
memcpy(bname, q1, q2-q1);
bname[q2-q1]='\0';
}
}
}
}
const char* pv = strstr(temp, "\"verses\"");
if (pv) sscanf(pv, "\"verses\"%*[: ]%d", &verses);
const char* pc = strstr(temp, "\"chapters\"");
if (pc) sscanf(pc, "\"chapters\"%*[: ]%d", &chapters);
if (bnum>0 && bname[0]) {
ctx->books[ctx->book_count].bnumber = bnum;
strncpy(ctx->books[ctx->book_count].bname, bname, MAX_BOOK_NAME-1);
ctx->books[ctx->book_count].verse_count = verses;
ctx->books[ctx->book_count].chapter_count = chapters;
ctx->book_count++;
}
p = obj_end+1;
// stop if ] seen before next {
const char* next_brace = strchr(p, '{');
const char* next_bracket = strchr(p, ']');
if (next_bracket && (!next_brace || next_bracket < next_brace)) break;
}
// total_verses
const char* pt = strstr(json_str, "\"total_verses\"");
if (pt) {
sscanf(pt, "\"total_verses\"%*[: ]%d", &ctx->total_verses);
} else {
ctx->total_verses=0;
for (int i=0;i<ctx->book_count;i++) ctx->total_verses+=ctx->books[i].verse_count;
}
return ctx->book_count>0;
}
static char* read_file_to_str(const char* path, size_t* out_len) {
FILE* f = fopen(path, "rb");
if (!f) return NULL;
fseek(f,0,SEEK_END);
long sz = ftell(f);
if (sz<=0 || sz>500000) { fclose(f); return NULL; }
fseek(f,0,SEEK_SET);
char* buf = malloc(sz+1);
if (!buf) { fclose(f); return NULL; }
size_t n = fread(buf,1,sz,f);
fclose(f);
buf[n]='\0';
if (out_len) *out_len=n;
return buf;
}
static bool load_books_manifest(AppCtx* ctx) {
char* json = read_file_to_str(ctx->books_json_path, NULL);
if (!json) {
// fallback: scan directory for *.bin and create synthetic list if books.json missing
ESP_LOGW(TAG, "books.json not found at %s, scanning dir", ctx->books_json_path);
DIR* d = opendir(ctx->bible_dir);
if (!d) {
ESP_LOGE(TAG, "Cannot open bible dir %s", ctx->bible_dir);
return false;
}
ctx->book_count=0;
ctx->total_verses=0;
struct dirent* ent;
while ((ent=readdir(d))!=NULL && ctx->book_count<MAX_BIBLE_BOOKS) {
if (ent->d_name[0]=='.') continue;
size_t l=strlen(ent->d_name);
if (l<5) continue;
if (strcmp(ent->d_name+l-4, ".bin")!=0) continue;
int bnum=0;
if (sscanf(ent->d_name, "%d", &bnum)!=1) continue;
// try read idx to get count
char idx_path[MAX_PATH];
snprintf(idx_path, sizeof(idx_path), "%s/%02d.idx", ctx->bible_dir, bnum);
FILE* idxf = fopen(idx_path, "rb");
int vcount=0;
if (idxf) {
uint8_t hdr[10]={0};
if (fread(hdr,1,10,idxf)==10) {
if (memcmp(hdr, "BIBK",4)==0) {
// format <4sHI
uint16_t ver; uint32_t cnt;
memcpy(&ver, hdr+4,2);
memcpy(&cnt, hdr+6,4);
vcount = (int)cnt;
} else {
// maybe old header <4sHI as earlier: try
// Actually we wrote <4sHI -> magic 4, H 2, I 4 =10 bytes
// So above.
}
}
fclose(idxf);
}
// generic name from bnumber if can't find
ctx->books[ctx->book_count].bnumber = bnum;
snprintf(ctx->books[ctx->book_count].bname, MAX_BOOK_NAME, "Book %02d", bnum);
ctx->books[ctx->book_count].verse_count = vcount;
ctx->books[ctx->book_count].chapter_count = 0;
ctx->book_count++;
ctx->total_verses+=vcount;
}
closedir(d);
// sort by bnumber
for (int i=0;i<ctx->book_count-1;i++) for (int j=0;j<ctx->book_count-i-1;j++) if (ctx->books[j].bnumber > ctx->books[j+1].bnumber) {
BookInfo tmp = ctx->books[j];
ctx->books[j]=ctx->books[j+1];
ctx->books[j+1]=tmp;
}
return ctx->book_count>0;
}
bool ok = parse_books_json(ctx, json);
free(json);
ESP_LOGI(TAG, "Loaded %d books, total verses %d from %s", ctx->book_count, ctx->total_verses, ctx->books_json_path);
return ok;
}
/* ── Book loading ── */
static bool load_book(AppCtx* ctx, int bnumber) {
if (ctx->loaded_bnumber == bnumber && ctx->book_bin && ctx->book_idx) return true;
unload_book(ctx);
char bin_path[MAX_PATH];
char idx_path[MAX_PATH];
snprintf(bin_path, sizeof(bin_path), "%s/%02d.bin", ctx->bible_dir, bnumber);
snprintf(idx_path, sizeof(idx_path), "%s/%02d.idx", ctx->bible_dir, bnumber);
if (!file_exists(bin_path) || !file_exists(idx_path)) {
ESP_LOGE(TAG, "Book files missing bnum=%d bin=%s idx=%s", bnumber, bin_path, idx_path);
return false;
}
// load idx first
FILE* f = fopen(idx_path, "rb");
if (!f) return false;
uint8_t hdr[10];
if (fread(hdr,1,10,f)!=10) { fclose(f); return false; }
if (memcmp(hdr, "BIBK",4)!=0) { fclose(f); return false; }
uint16_t ver=0; uint32_t count=0;
memcpy(&ver, hdr+4, 2);
memcpy(&count, hdr+6, 4);
if (count==0 || count>10000) { fclose(f); return false; }
ctx->book_idx = malloc(sizeof(VerseIndex)*count);
if (!ctx->book_idx) { fclose(f); return false; }
for (uint32_t i=0;i<count;i++) {
uint8_t entry[8];
if (fread(entry,1,8,f)!=8) { free(ctx->book_idx); ctx->book_idx=NULL; fclose(f); return false; }
uint32_t off; uint16_t cnum, vnum;
memcpy(&off, entry,4);
memcpy(&cnum, entry+4,2);
memcpy(&vnum, entry+6,2);
ctx->book_idx[i].offset=off;
ctx->book_idx[i].cnum=cnum;
ctx->book_idx[i].vnum=vnum;
}
fclose(f);
ctx->book_idx_count = (int)count;
// load bin
f = fopen(bin_path, "rb");
if (!f) { free(ctx->book_idx); ctx->book_idx=NULL; return false; }
fseek(f,0,SEEK_END);
long sz = ftell(f);
fseek(f,0,SEEK_SET);
if (sz<=0 || sz>1024*1024) { fclose(f); free(ctx->book_idx); ctx->book_idx=NULL; return false; }
ctx->book_bin = malloc(sz+1);
if (!ctx->book_bin) { fclose(f); free(ctx->book_idx); ctx->book_idx=NULL; return false; }
size_t n = fread(ctx->book_bin,1,sz,f);
fclose(f);
ctx->book_bin[sz]='\0';
ctx->book_bin_size = n;
ctx->loaded_bnumber = bnumber;
ESP_LOGI(TAG, "Loaded book %02d (%d verses, %ld bytes)", bnumber, ctx->book_idx_count, sz);
return true;
}
static void unload_book(AppCtx* ctx) {
if (ctx->book_bin) { free(ctx->book_bin); ctx->book_bin=NULL; }
if (ctx->book_idx) { free(ctx->book_idx); ctx->book_idx=NULL; }
ctx->book_bin_size=0;
ctx->book_idx_count=0;
ctx->loaded_bnumber=-1;
}
/* ── Progress persistence ── */
static void save_progress(AppCtx* ctx) {
ensure_user_dir(ctx);
FILE* f = fopen(ctx->progress_path, "w");
if (!f) { ESP_LOGE(TAG,"save_progress fail %s", ctx->progress_path); return; }
// Store global index and also b/c/v for human debug
int bnum = 0; int cnum=1; int vnum=1;
if (ctx->cur_book_idx>=0 && ctx->cur_book_idx < ctx->book_count) {
bnum = ctx->books[ctx->cur_book_idx].bnumber;
if (ctx->book_idx && ctx->cur_verse_in_book < ctx->book_idx_count) {
cnum = ctx->book_idx[ctx->cur_verse_in_book].cnum;
vnum = ctx->book_idx[ctx->cur_verse_in_book].vnum;
}
}
fprintf(f, "%d\n%d %d %d\n%d %d\n", ctx->cur_global, bnum, cnum, vnum, ctx->cur_book_idx, ctx->cur_verse_in_book);
fclose(f);
ESP_LOGI(TAG,"Saved progress global=%d b=%d c=%d v=%d", ctx->cur_global, bnum, cnum, vnum);
}
static bool load_progress(AppCtx* ctx) {
FILE* f = fopen(ctx->progress_path, "r");
if (!f) return false;
int global_=0;
int bnum=0,cnum=0,vnum=0;
int book_idx_=0, verse_in_book_=0;
int r = fscanf(f, "%d", &global_);
if (r==1) {
// try second line
fscanf(f, "%d %d %d", &bnum, &cnum, &vnum);
fscanf(f, "%d %d", &book_idx_, &verse_in_book_);
}
fclose(f);
if (global_<0) global_=0;
if (global_>=ctx->total_verses) global_=0;
ctx->cur_global = global_;
// We will resolve book_idx / verse_in_book from global later in global_to_book()
(void)bnum;(void)cnum;(void)vnum;(void)book_idx_;(void)verse_in_book_;
return true;
}
// Convert global idx -> book_idx and verse_in_book
static void global_to_book(AppCtx* ctx, int global) {
if (ctx->book_count==0) return;
int remaining = global;
for (int i=0;i<ctx->book_count;i++) {
int vc = ctx->books[i].verse_count;
if (vc<=0) continue;
if (remaining < vc) {
ctx->cur_book_idx = i;
ctx->cur_verse_in_book = remaining;
return;
}
remaining -= vc;
}
// clamp to last
ctx->cur_book_idx = ctx->book_count-1;
ctx->cur_verse_in_book = ctx->books[ctx->book_count-1].verse_count-1;
if (ctx->cur_verse_in_book<0) ctx->cur_verse_in_book=0;
}
static void book_to_global(AppCtx* ctx) {
int g=0;
for (int i=0;i<ctx->cur_book_idx;i++) g+=ctx->books[i].verse_count;
g+=ctx->cur_verse_in_book;
ctx->cur_global=g;
}
/* ── Favorites ── */
static bool is_favorite(AppCtx* ctx, int global_idx) {
if (global_idx<0) return false;
FILE* f = fopen(ctx->favorites_path, "r");
if (!f) return false;
int v;
while (fscanf(f, "%d", &v)==1) {
if (v==global_idx) { fclose(f); return true; }
}
fclose(f);
return false;
}
static void toggle_favorite(AppCtx* ctx) {
// load all
int vals[512];
int cnt=0;
FILE* f = fopen(ctx->favorites_path, "r");
if (f) {
while (cnt<512 && fscanf(f, "%d", &vals[cnt])==1) cnt++;
fclose(f);
}
// check existence
int idx=-1;
for (int i=0;i<cnt;i++) if (vals[i]==ctx->cur_global) idx=i;
if (idx>=0) {
// remove
for (int i=idx;i<cnt-1;i++) vals[i]=vals[i+1];
cnt--;
ctx->is_highlighted=false;
} else {
if (cnt<512) {
vals[cnt++]=ctx->cur_global;
ctx->is_highlighted=true;
}
}
ensure_user_dir(ctx);
f = fopen(ctx->favorites_path, "w");
if (!f) return;
for (int i=0;i<cnt;i++) fprintf(f, "%d\n", vals[i]);
fclose(f);
}
/* ── UI logic ── */
static void apply_verse_scaling(AppCtx* ctx, const char* verse) {
if (!ctx->lbl_verse) return;
size_t len = strlen(verse);
// Tiers tuned from real device screenshots (240x320 style)
// < 40 : ultra short e.g. "Jesus wept." -> giant, centered, lots of breathing
// 40-100 : short -> large
// 100-200: medium -> default/large borderline
// 200-350: long -> small/default
// 350+ : very long (Esther 8:9 etc) -> extra compact
enum LvglFontSize font_sz;
int line_space;
int pad_v;
int pad_h;
int letter_space = 0;
if (len < 40) {
font_sz = FONT_SIZE_LARGE;
line_space = 14;
pad_v = 32;
pad_h = 36;
letter_space = 1;
} else if (len < 100) {
font_sz = FONT_SIZE_LARGE;
line_space = 11;
pad_v = 24;
pad_h = 28;
} else if (len < 200) {
font_sz = FONT_SIZE_DEFAULT;
line_space = 9;
pad_v = 18;
pad_h = 22;
} else if (len < 350) {
font_sz = FONT_SIZE_SMALL;
line_space = 6;
pad_v = 12;
pad_h = 18;
} else {
font_sz = FONT_SIZE_SMALL;
line_space = 4;
pad_v = 8;
pad_h = 14;
}
lv_obj_set_style_text_font(ctx->lbl_verse, lvgl_get_text_font(font_sz), 0);
lv_obj_set_style_text_line_space(ctx->lbl_verse, line_space, 0);
lv_obj_set_style_text_letter_space(ctx->lbl_verse, letter_space, 0);
lv_obj_set_style_pad_top(ctx->lbl_verse, pad_v, 0);
lv_obj_set_style_pad_bottom(ctx->lbl_verse, pad_v, 0);
lv_obj_set_style_pad_left(ctx->lbl_verse, pad_h, 0);
lv_obj_set_style_pad_right(ctx->lbl_verse, pad_h, 0);
}
static void show_current_verse(AppCtx* ctx) {
if (ctx->book_count==0) return;
global_to_book(ctx, ctx->cur_global);
book_to_global(ctx);
int bnum = ctx->books[ctx->cur_book_idx].bnumber;
if (!load_book(ctx, bnum)) {
lv_label_set_text(ctx->lbl_verse, "Failed to load book");
lv_label_set_text(ctx->lbl_ref, "");
return;
}
if (ctx->cur_verse_in_book<0) ctx->cur_verse_in_book=0;
if (ctx->cur_verse_in_book>=ctx->book_idx_count) ctx->cur_verse_in_book=ctx->book_idx_count-1;
VerseIndex* vi = &ctx->book_idx[ctx->cur_verse_in_book];
uint32_t off = vi->offset;
const char* text_ptr = "(empty)";
if (off < ctx->book_bin_size) text_ptr = (const char*)(ctx->book_bin + off);
char verse_buf[MAX_VERSE_TEXT];
strncpy(verse_buf, text_ptr, sizeof(verse_buf)-1);
verse_buf[sizeof(verse_buf)-1]='\0';
char ref_buf[96];
snprintf(ref_buf, sizeof(ref_buf), "%s %d:%d", ctx->books[ctx->cur_book_idx].bname, vi->cnum, vi->vnum);
char ref_pretty[96];
snprintf(ref_pretty, sizeof(ref_pretty), "— %s", ref_buf);
if (ctx->lbl_verse) {
lv_label_set_text(ctx->lbl_verse, verse_buf);
apply_verse_scaling(ctx, verse_buf);
}
if (ctx->lbl_ref) lv_label_set_text(ctx->lbl_ref, ref_pretty);
if (ctx->lbl_book_title) lv_label_set_text(ctx->lbl_book_title, ref_buf);
if (ctx->lbl_position) {
char pos_buf[40];
snprintf(pos_buf, sizeof(pos_buf), "%d / %d", ctx->cur_global+1, ctx->total_verses);
lv_label_set_text(ctx->lbl_position, pos_buf);
}
ctx->is_highlighted = is_favorite(ctx, ctx->cur_global);
if (ctx->btn_highlight) {
lv_obj_t* lbl = lv_obj_get_child(ctx->btn_highlight, 0);
if (lbl) lv_label_set_text(lbl, ctx->is_highlighted ? LV_SYMBOL_OK : LV_SYMBOL_DUMMY);
if (ctx->is_highlighted) {
lv_obj_set_style_bg_color(ctx->btn_highlight, lv_color_hex(0xF5C16C), 0);
lv_obj_set_style_text_color(ctx->btn_highlight, lv_color_hex(0x1E1E2E), 0);
} else {
lv_obj_set_style_bg_color(ctx->btn_highlight, lv_color_hex(0x252538), 0);
lv_obj_set_style_text_color(ctx->btn_highlight, lv_color_hex(0x8C8CB0), 0);
}
if (ctx->lbl_verse) {
if (ctx->is_highlighted) {
lv_obj_set_style_bg_color(ctx->lbl_verse, lv_color_hex(0x2E2818), 0);
lv_obj_set_style_bg_opa(ctx->lbl_verse, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(ctx->lbl_verse, lv_color_hex(0xF5C16C), 0);
lv_obj_set_style_border_width(ctx->lbl_verse, 1, 0);
lv_obj_set_style_border_opa(ctx->lbl_verse, LV_OPA_40, 0);
} else {
lv_obj_set_style_bg_opa(ctx->lbl_verse, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(ctx->lbl_verse, 0, 0);
}
}
}
if (ctx->btn_prev) {
if (ctx->cur_global<=0) lv_obj_add_state(ctx->btn_prev, LV_STATE_DISABLED);
else lv_obj_clear_state(ctx->btn_prev, LV_STATE_DISABLED);
}
if (ctx->btn_next) {
if (ctx->cur_global >= ctx->total_verses-1) lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED);
else lv_obj_clear_state(ctx->btn_next, LV_STATE_DISABLED);
}
save_progress(ctx);
}
static void advance_verse(AppCtx* ctx, int delta) {
int ng = ctx->cur_global + delta;
if (ng < 0) ng = 0;
if (ng >= ctx->total_verses) ng = ctx->total_verses-1; // stop at end, or wrap? we stop
// Optionally wrap to start after Revelation
// if (ng >= ctx->total_verses) ng = 0;
if (ng != ctx->cur_global) {
ctx->cur_global = ng;
show_current_verse(ctx);
}
}
/* ── Timer ── */
static void on_auto_timer(lv_timer_t* t) {
AppCtx* ctx = (AppCtx*)lv_timer_get_user_data(t);
if (ctx->auto_paused) return;
if (ctx->cur_global < ctx->total_verses-1) {
advance_verse(ctx, 1);
} else {
ctx->auto_paused = true;
if (ctx->btn_play_pause) {
lv_obj_t* lbl = lv_obj_get_child(ctx->btn_play_pause, 0);
if (lbl) lv_label_set_text(lbl, LV_SYMBOL_PLAY);
}
}
}
/* ── UI callbacks ── */
static void on_verse_tap(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
toggle_ui(ctx);
}
static void toggle_ui(AppCtx* ctx) {
set_chrome_visible(ctx, !ctx->ui_visible);
}
static void set_chrome_visible(AppCtx* ctx, bool visible) {
ctx->ui_visible = visible;
if (visible) {
if (ctx->header_bar) lv_obj_remove_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
if (ctx->ctrl_bar) lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
} else {
if (ctx->header_bar) lv_obj_add_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
if (ctx->ctrl_bar) lv_obj_add_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
}
}
static void on_prev_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
advance_verse(ctx, -1);
}
static void on_next_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
advance_verse(ctx, 1);
}
static void on_play_pause_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
ctx->auto_paused = !ctx->auto_paused;
if (ctx->btn_play_pause) {
lv_obj_t* lbl = lv_obj_get_child(ctx->btn_play_pause, 0);
if (lbl) lv_label_set_text(lbl, ctx->auto_paused ? LV_SYMBOL_PLAY : LV_SYMBOL_PAUSE);
}
}
static void on_highlight_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
toggle_favorite(ctx);
// refresh highlight visuals without reloading
show_current_verse(ctx);
}
static void on_close_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
(void)ctx;
tt_app_stop();
}
// Future hooks
static void on_memo_click(lv_event_t* e) {
// Placeholder: in future record voice memo to /sdcard/bible_memos/<global>.wav
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
const char* btns[] = {"OK"};
tt_app_alertdialog_start("Voice Memo", "Voice memo recording will be added in a future version.\nFile will be saved under /sdcard/bible_memos/", btns, 1);
}
/* ── App lifecycle ── */
static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
memset(&g_ctx, 0, sizeof(g_ctx));
g_ctx.app = app;
g_ctx.loaded_bnumber = -1;
g_ctx.auto_paused = false;
g_ctx.ui_visible = false;
g_ctx.root = parent;
char ud_path[MAX_PATH]; size_t sz=sizeof(ud_path);
tt_app_get_user_data_path(app, ud_path, &sz);
snprintf(g_ctx.progress_path, sizeof(g_ctx.progress_path), "%s/progress.txt", ud_path);
snprintf(g_ctx.favorites_path, sizeof(g_ctx.favorites_path), "%s/favorites.txt", ud_path);
resolve_bible_paths(&g_ctx);
if (!load_books_manifest(&g_ctx)) {
lv_obj_t* lbl = lv_label_create(parent);
lv_label_set_text(lbl, "Bible data not found.\n\nExpected:\n assets/bible/books.json\n or /sdcard/bibles/\n\nRun tools/convert_bible.py");
lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0);
lv_label_set_long_mode(lbl, LV_LABEL_LONG_MODE_WRAP);
lv_obj_set_width(lbl, LV_PCT(80));
return;
}
if (!load_progress(&g_ctx)) g_ctx.cur_global = 0;
if (g_ctx.cur_global <0 || g_ctx.cur_global >= g_ctx.total_verses) g_ctx.cur_global=0;
// Deep paper dark - eye friendly for meditation
lv_obj_set_style_bg_color(parent, lv_color_hex(0x0E0E14), 0);
lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, 0);
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_pad_all(parent, 0, 0);
// Full screen tap area
g_ctx.verse_wrapper = lv_obj_create(parent);
lv_obj_set_size(g_ctx.verse_wrapper, LV_PCT(100), LV_PCT(100));
lv_obj_align(g_ctx.verse_wrapper, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_style_bg_opa(g_ctx.verse_wrapper, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(g_ctx.verse_wrapper, 0, 0);
lv_obj_set_style_pad_all(g_ctx.verse_wrapper, 0, 0);
lv_obj_remove_flag(g_ctx.verse_wrapper, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(g_ctx.verse_wrapper, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(g_ctx.verse_wrapper, on_verse_tap, LV_EVENT_CLICKED, &g_ctx);
// Center reading column with generous breathing room
lv_obj_t* center_col = lv_obj_create(g_ctx.verse_wrapper);
lv_obj_set_size(center_col, LV_PCT(90), LV_PCT(80));
lv_obj_center(center_col);
lv_obj_set_style_bg_opa(center_col, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(center_col, 0, 0);
lv_obj_set_flex_flow(center_col, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(center_col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_gap(center_col, 14, 0);
lv_obj_set_style_pad_all(center_col, 0, 0);
lv_obj_remove_flag(center_col, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(center_col, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(center_col, on_verse_tap, LV_EVENT_CLICKED, &g_ctx);
g_ctx.lbl_verse = lv_label_create(center_col);
lv_obj_set_width(g_ctx.lbl_verse, LV_PCT(100));
lv_label_set_long_mode(g_ctx.lbl_verse, LV_LABEL_LONG_MODE_WRAP);
lv_obj_set_style_text_color(g_ctx.lbl_verse, lv_color_hex(0xE8E6E0), 0);
lv_obj_set_style_text_font(g_ctx.lbl_verse, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
lv_obj_set_style_text_align(g_ctx.lbl_verse, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_line_space(g_ctx.lbl_verse, 8, 0);
lv_obj_set_style_text_letter_space(g_ctx.lbl_verse, 0, 0);
lv_obj_set_style_radius(g_ctx.lbl_verse, 16, 0);
lv_obj_set_style_pad_all(g_ctx.lbl_verse, 16, 0);
lv_label_set_text(g_ctx.lbl_verse, "Loading...");
g_ctx.lbl_ref = lv_label_create(center_col);
lv_obj_set_style_text_color(g_ctx.lbl_ref, lv_color_hex(0x7A7A96), 0);
lv_obj_set_style_text_font(g_ctx.lbl_ref, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_align(g_ctx.lbl_ref, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_letter_space(g_ctx.lbl_ref, 1, 0);
lv_label_set_text(g_ctx.lbl_ref, "");
// ── Header bar (frosted dark) ──
g_ctx.header_bar = lv_obj_create(parent);
lv_obj_t* hb = g_ctx.header_bar;
lv_obj_set_size(hb, LV_PCT(100), 44);
lv_obj_align(hb, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_set_style_radius(hb, 0, 0);
lv_obj_set_style_bg_color(hb, lv_color_hex(0x15151F), 0);
lv_obj_set_style_bg_opa(hb, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(hb, 0, 0);
lv_obj_set_style_border_side(hb, LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_border_color(hb, lv_color_hex(0x232338), 0);
lv_obj_set_style_border_width(hb, 1, 0);
lv_obj_set_style_pad_hor(hb, 12, 0);
lv_obj_set_style_pad_ver(hb, 0, 0);
lv_obj_remove_flag(hb, LV_OBJ_FLAG_SCROLLABLE);
g_ctx.lbl_book_title = lv_label_create(hb);
lv_obj_align(g_ctx.lbl_book_title, LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_set_style_text_color(g_ctx.lbl_book_title, lv_color_hex(0xE8E6E0), 0);
lv_obj_set_style_text_font(g_ctx.lbl_book_title, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_label_set_text(g_ctx.lbl_book_title, "Bible");
g_ctx.lbl_position = lv_label_create(hb);
lv_obj_align(g_ctx.lbl_position, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_style_text_color(g_ctx.lbl_position, lv_color_hex(0x6A6A8A), 0);
lv_obj_set_style_text_font(g_ctx.lbl_position, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_label_set_text(g_ctx.lbl_position, "1 / 1");
// ── Bottom controls (minimal) ──
g_ctx.ctrl_bar = lv_obj_create(parent);
lv_obj_t* cb = g_ctx.ctrl_bar;
lv_obj_set_size(cb, LV_PCT(100), 56);
lv_obj_align(cb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_set_style_radius(cb, 0, 0);
lv_obj_set_style_bg_color(cb, lv_color_hex(0x15151F), 0);
lv_obj_set_style_bg_opa(cb, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(cb, 1, 0);
lv_obj_set_style_border_side(cb, LV_BORDER_SIDE_TOP, 0);
lv_obj_set_style_border_color(cb, lv_color_hex(0x232338), 0);
lv_obj_set_style_pad_all(cb, 6, 0);
lv_obj_set_style_pad_gap(cb, 4, 0);
lv_obj_set_flex_flow(cb, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(cb, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(cb, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* btn;
lv_obj_t* lbl;
g_ctx.btn_close = lv_button_create(cb);
lv_obj_set_size(g_ctx.btn_close, 38, 38);
lv_obj_set_style_radius(g_ctx.btn_close, 10, 0);
lv_obj_set_style_bg_color(g_ctx.btn_close, lv_color_hex(0x1E1E2E), 0);
lv_obj_set_style_text_color(g_ctx.btn_close, lv_color_hex(0x8C8CB0), 0);
lbl = lv_label_create(g_ctx.btn_close); lv_label_set_text(lbl, LV_SYMBOL_CLOSE); lv_obj_center(lbl);
lv_obj_add_event_cb(g_ctx.btn_close, on_close_click, LV_EVENT_CLICKED, &g_ctx);
g_ctx.btn_prev = lv_button_create(cb);
lv_obj_set_size(g_ctx.btn_prev, 44, 38);
lv_obj_set_style_radius(g_ctx.btn_prev, 10, 0);
lv_obj_set_style_bg_color(g_ctx.btn_prev, lv_color_hex(0x252538), 0);
lv_obj_set_style_text_color(g_ctx.btn_prev, lv_color_hex(0xC4C4D4), 0);
lbl = lv_label_create(g_ctx.btn_prev); lv_label_set_text(lbl, LV_SYMBOL_PREV); lv_obj_center(lbl);
lv_obj_add_event_cb(g_ctx.btn_prev, on_prev_click, LV_EVENT_CLICKED, &g_ctx);
g_ctx.btn_play_pause = lv_button_create(cb);
lv_obj_set_size(g_ctx.btn_play_pause, 76, 38);
lv_obj_set_style_radius(g_ctx.btn_play_pause, 19, 0);
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x2A2A4A), 0);
lv_obj_set_style_text_color(g_ctx.btn_play_pause, lv_color_hex(0xE8E6E0), 0);
lbl = lv_label_create(g_ctx.btn_play_pause); lv_label_set_text(lbl, LV_SYMBOL_PAUSE); lv_obj_center(lbl);
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
g_ctx.btn_next = lv_button_create(cb);
lv_obj_set_size(g_ctx.btn_next, 44, 38);
lv_obj_set_style_radius(g_ctx.btn_next, 10, 0);
lv_obj_set_style_bg_color(g_ctx.btn_next, lv_color_hex(0x252538), 0);
lv_obj_set_style_text_color(g_ctx.btn_next, lv_color_hex(0xC4C4D4), 0);
lbl = lv_label_create(g_ctx.btn_next); lv_label_set_text(lbl, LV_SYMBOL_NEXT); lv_obj_center(lbl);
lv_obj_add_event_cb(g_ctx.btn_next, on_next_click, LV_EVENT_CLICKED, &g_ctx);
g_ctx.btn_highlight = lv_button_create(cb);
lv_obj_set_size(g_ctx.btn_highlight, 44, 38);
lv_obj_set_style_radius(g_ctx.btn_highlight, 10, 0);
lv_obj_set_style_bg_color(g_ctx.btn_highlight, lv_color_hex(0x252538), 0);
lv_obj_set_style_text_color(g_ctx.btn_highlight, lv_color_hex(0x8C8CB0), 0);
lbl = lv_label_create(g_ctx.btn_highlight); lv_label_set_text(lbl, LV_SYMBOL_DUMMY); lv_obj_center(lbl);
lv_obj_add_event_cb(g_ctx.btn_highlight, on_highlight_click, LV_EVENT_CLICKED, &g_ctx);
btn = lv_button_create(cb);
lv_obj_set_size(btn, 38, 38);
lv_obj_set_style_radius(btn, 10, 0);
lv_obj_set_style_bg_color(btn, lv_color_hex(0x1E1E2E), 0);
lv_obj_set_style_text_color(btn, lv_color_hex(0x6A6A8A), 0);
lbl = lv_label_create(btn); lv_label_set_text(lbl, LV_SYMBOL_AUDIO); lv_obj_center(lbl);
lv_obj_add_event_cb(btn, on_memo_click, LV_EVENT_CLICKED, &g_ctx);
set_chrome_visible(&g_ctx, false);
show_current_verse(&g_ctx);
g_ctx.auto_timer = lv_timer_create(on_auto_timer, AUTO_ADV_MS, &g_ctx);
}
static void onHideApp(AppHandle app, void* data) {
(void)app; (void)data;
if (g_ctx.auto_timer) {
lv_timer_delete(g_ctx.auto_timer);
g_ctx.auto_timer = NULL;
}
save_progress(&g_ctx);
unload_book(&g_ctx);
// UI objects are deleted by LVGL parent cleanup
memset(&g_ctx, 0, sizeof(g_ctx));
g_ctx.loaded_bnumber = -1;
}
int main(int argc, char* argv[]) {
tt_app_register((AppRegistration){
.onShow = onShowApp,
.onHide = onHideApp
});
return 0;
}