Compare commits
7 Commits
1a654da3e9
..
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bc1263294 | |||
| a3e437277f | |||
| ff2b43f1e0 | |||
| 4e83ebbf50 | |||
| 7a77c4a07d | |||
| 1ebbfebe31 | |||
| b94e76931a |
@@ -1,7 +1,14 @@
|
|||||||
name: build
|
name: build
|
||||||
|
|
||||||
# Build a versioned Linux CLI release tarball (see `make package`) on
|
# Build a versioned Linux CLI release tarball (see `make package`), the
|
||||||
# every push/PR, plus on-demand via the Gitea "Run workflow" button.
|
# Pebble watchapp (see `make watchapp`), and the Android app (see `make
|
||||||
|
# android`) on every push/PR, plus on-demand via the Gitea "Run workflow"
|
||||||
|
# button. Runs inside build-image (see ../../build-image/Dockerfile,
|
||||||
|
# built/pushed via ../../build-image.sh and ../../upload-image.sh), which
|
||||||
|
# bundles the Pebble SDK and the Android SDK/NDK alongside the C
|
||||||
|
# toolchain. Unlike the CLI/Pebble build path, the Android build still
|
||||||
|
# needs network access at job runtime (Gradle/AGP/androidx dependency
|
||||||
|
# resolution) - see the root CLAUDE.md's Android section.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -17,12 +24,18 @@ jobs:
|
|||||||
# Must match a label your act_runner is registered with.
|
# Must match a label your act_runner is registered with.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: cr.ladkau.de/deck-in-a-dash/builder:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# `make package`'s version comes from `git describe --tags` (see
|
# `make package`/`make watchapp`'s version comes from `git
|
||||||
# the Makefile's `package` target) - needs full history/tags,
|
# describe --tags` - needs full history/tags, not
|
||||||
# not actions/checkout's default shallow single-commit clone.
|
# actions/checkout's default shallow single-commit clone.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Preflight
|
- name: Preflight
|
||||||
@@ -30,12 +43,18 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
command -v cc >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: no C compiler (cc) in PATH" >&2; exit 1; }
|
command -v cc >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: no C compiler (cc) in PATH" >&2; exit 1; }
|
||||||
command -v make >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: make not in PATH" >&2; exit 1; }
|
command -v make >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: make not in PATH" >&2; exit 1; }
|
||||||
|
command -v pebble >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: pebble not in PATH" >&2; exit 1; }
|
||||||
|
command -v java >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: java not in PATH" >&2; exit 1; }
|
||||||
|
[ -n "${ANDROID_HOME:-}" ] || { echo "PREFLIGHT FAIL: ANDROID_HOME not set" >&2; exit 1; }
|
||||||
|
[ -n "${ANDROID_NDK_HOME:-}" ] || { echo "PREFLIGHT FAIL: ANDROID_NDK_HOME not set" >&2; exit 1; }
|
||||||
|
|
||||||
- name: Build and test
|
- name: Build and test
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
make test
|
make test
|
||||||
make package
|
make package
|
||||||
|
make watchapp
|
||||||
|
make android
|
||||||
|
|
||||||
- name: Upload build artifact
|
- name: Upload build artifact
|
||||||
# v4 uses the newer @actions/artifact backend, which this Gitea
|
# v4 uses the newer @actions/artifact backend, which this Gitea
|
||||||
@@ -45,14 +64,28 @@ jobs:
|
|||||||
name: deck-in-a-dash
|
name: deck-in-a-dash
|
||||||
path: dist/deck-in-a-dash-*-linux-*.tar.gz
|
path: dist/deck-in-a-dash-*-linux-*.tar.gz
|
||||||
|
|
||||||
|
- name: Upload watchapp artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: deck-in-a-dash-watchapp
|
||||||
|
path: dist/deck-in-a-dash-*.pbw
|
||||||
|
|
||||||
|
- name: Upload android artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: deck-in-a-dash-android
|
||||||
|
path: dist/deck-in-a-dash-*.apk
|
||||||
|
|
||||||
- name: Publish to dl.ladkau.de
|
- name: Publish to dl.ladkau.de
|
||||||
# Uploads the tarball over SFTP instead of using
|
# Uploads the tarball, .pbw, and .apk over SFTP instead of using
|
||||||
# actions/upload-artifact (whose zip wrapping can't be disabled).
|
# actions/upload-artifact (whose zip wrapping can't be disabled).
|
||||||
# Only runs on push so PR builds don't publish.
|
# Only runs on push so PR builds don't publish.
|
||||||
if: gitea.event_name == 'push'
|
if: gitea.event_name == 'push'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
FILE="$(ls dist/deck-in-a-dash-*-linux-*.tar.gz)"
|
TARBALL="$(ls dist/deck-in-a-dash-*-linux-*.tar.gz)"
|
||||||
|
PBW="$(ls dist/deck-in-a-dash-*.pbw)"
|
||||||
|
APK="$(ls dist/deck-in-a-dash-*.apk)"
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
|
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
|
||||||
chmod 600 ~/.ssh/dl_sftp_key
|
chmod 600 ~/.ssh/dl_sftp_key
|
||||||
@@ -60,5 +93,7 @@ jobs:
|
|||||||
-o StrictHostKeyChecking=accept-new \
|
-o StrictHostKeyChecking=accept-new \
|
||||||
uploader@dl.ladkau.de <<EOF
|
uploader@dl.ladkau.de <<EOF
|
||||||
-mkdir files/deck-in-a-dash
|
-mkdir files/deck-in-a-dash
|
||||||
put $FILE files/deck-in-a-dash/$(basename "$FILE")
|
put $TARBALL files/deck-in-a-dash/$(basename "$TARBALL")
|
||||||
|
put $PBW files/deck-in-a-dash/$(basename "$PBW")
|
||||||
|
put $APK files/deck-in-a-dash/$(basename "$APK")
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
+28
@@ -5,6 +5,30 @@
|
|||||||
/watch/build
|
/watch/build
|
||||||
/watch/src/c/i18n_tables.auto.c
|
/watch/src/c/i18n_tables.auto.c
|
||||||
/watch/resources/img
|
/watch/resources/img
|
||||||
|
/watch/resources/app_icon.png
|
||||||
|
|
||||||
|
# waf's own build lock file - regenerated every `pebble build`, holds
|
||||||
|
# absolute machine paths and a full dump of the invoking shell's
|
||||||
|
# environment variables. Was tracked accidentally; see the commit that
|
||||||
|
# added this .gitignore line for the git rm --cached that untracked it.
|
||||||
|
/watch/.lock-waf_linux_build
|
||||||
|
|
||||||
|
# Android app build output/local config and generated resources (card
|
||||||
|
# art, launcher icon, synced i18n files - see android/app/build.gradle.kts's
|
||||||
|
# generateCardArt/generateLauncherIcon/syncI18n tasks and their own
|
||||||
|
# source scripts' doc comments).
|
||||||
|
/android/local.properties
|
||||||
|
/android/.gradle
|
||||||
|
/android/build
|
||||||
|
/android/app/build
|
||||||
|
/android/app/.cxx
|
||||||
|
/android/app/src/main/assets/i18n
|
||||||
|
/android/app/src/main/res/drawable-nodpi
|
||||||
|
/android/app/src/main/res/mipmap-mdpi
|
||||||
|
/android/app/src/main/res/mipmap-hdpi
|
||||||
|
/android/app/src/main/res/mipmap-xhdpi
|
||||||
|
/android/app/src/main/res/mipmap-xxhdpi
|
||||||
|
/android/app/src/main/res/mipmap-xxxhdpi
|
||||||
|
|
||||||
# Durable backup of the same real birth data, outside dist/ so a dist/
|
# Durable backup of the same real birth data, outside dist/ so a dist/
|
||||||
# wipe doesn't lose it — see the Makefile's `scripts` target. Never
|
# wipe doesn't lose it — see the Makefile's `scripts` target. Never
|
||||||
@@ -16,3 +40,7 @@ core
|
|||||||
core.*
|
core.*
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Container registry credentials for build-image.sh/run-image.sh/
|
||||||
|
# upload-image.sh — never commit; copy registry.env.example instead.
|
||||||
|
/registry.env
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ make # builds dist/{deck-engine,interpreter-cli}, dist/img/, dist/i18n/
|
|||||||
make test # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c
|
make test # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c
|
||||||
make clean # removes build/ and the generated binaries/img/i18n (never touches dist/user.properties)
|
make clean # removes build/ and the generated binaries/img/i18n (never touches dist/user.properties)
|
||||||
make package # builds a versioned Linux CLI tarball at dist/ - see "Packaging" below
|
make package # builds a versioned Linux CLI tarball at dist/ - see "Packaging" below
|
||||||
|
make watchapp # builds the Pebble watchapp and copies a versioned .pbw to
|
||||||
|
# dist/ - see "Packaging" below. Requires the Pebble SDK
|
||||||
|
# (not needed for anything else above); not run by CI.
|
||||||
```
|
```
|
||||||
|
|
||||||
A single `Makefile` at the repo root builds both `engine/` and
|
A single `Makefile` at the repo root builds both `engine/` and
|
||||||
@@ -101,6 +104,114 @@ published over SFTP to `dl.ladkau.de` under `files/deck-in-a-dash/` -
|
|||||||
requires a `DL_SFTP_KEY` secret configured on this repo (or inherited
|
requires a `DL_SFTP_KEY` secret configured on this repo (or inherited
|
||||||
from the org/instance level).
|
from the org/instance level).
|
||||||
|
|
||||||
|
### Watch app packaging (`make watchapp`)
|
||||||
|
|
||||||
|
A separate target, deliberately **not** a prerequisite of `all` - it
|
||||||
|
shells out to `pebble build` inside `watch/` (via `watch/wscript`'s own
|
||||||
|
Pebble SDK/waf tooling, not this Makefile's plain-`cc` rules) and copies
|
||||||
|
the resulting `watch/build/watch.pbw` to
|
||||||
|
`dist/deck-in-a-dash-<version>.pbw`, using the exact same version
|
||||||
|
resolution as `make package` above (current git tag, or `make watchapp
|
||||||
|
VERSION=1.2.3` to override - see that section, not duplicated here).
|
||||||
|
Requires the Pebble SDK (`pebble` on `PATH`, plus the Python 3 + Pillow
|
||||||
|
`watch/wscript` needs for its resource-generation scripts) - a machine
|
||||||
|
without it can still build/test/package the CLI via every other target
|
||||||
|
in this file.
|
||||||
|
|
||||||
|
`.gitea/workflows/build.yml` **does** run `make watchapp` on every
|
||||||
|
push/PR, alongside `make test`/`make package` - the whole job runs
|
||||||
|
inside a container image (`build-image/`, built and pushed with
|
||||||
|
`build-image.sh`/`upload-image.sh`, see "Build image" below) that has
|
||||||
|
the Pebble SDK baked in, so the CI runner itself needs no toolchain
|
||||||
|
beyond Docker access to pull that image. The CLI tarball, the `.pbw`,
|
||||||
|
and the Android `.apk` (see "Android app packaging" below) are all
|
||||||
|
uploaded as build artifacts and published to
|
||||||
|
`dl.ladkau.de/files/deck-in-a-dash/` on push.
|
||||||
|
|
||||||
|
### Android app packaging (`make android`)
|
||||||
|
|
||||||
|
A separate target, deliberately **not** a prerequisite of `all` - it
|
||||||
|
shells out to `./gradlew assembleDebug` inside `android/` (via that
|
||||||
|
directory's own Gradle/AGP/NDK toolchain, not this Makefile's plain-`cc`
|
||||||
|
rules) and copies the resulting
|
||||||
|
`android/app/build/outputs/apk/debug/app-debug.apk` to
|
||||||
|
`dist/deck-in-a-dash-<version>.apk`, using the exact same version
|
||||||
|
resolution as `make package`/`make watchapp` above (current git tag, or
|
||||||
|
`make android VERSION=1.2.3` to override). Requires the Android SDK/NDK
|
||||||
|
(`ANDROID_HOME`/`ANDROID_NDK_HOME` set, plus the Python 3 + Pillow
|
||||||
|
`android/app/build.gradle.kts`'s `preBuild` task needs for its own
|
||||||
|
resource-generation scripts - see `android/README.md`) - a machine
|
||||||
|
without it can still build/test/package the CLI and the watch app via
|
||||||
|
every other target in this file.
|
||||||
|
|
||||||
|
See `android/README.md` for the app's own architecture summary; in
|
||||||
|
short, it's a Jetpack Compose Material3 app whose native layer links the
|
||||||
|
same `engine/src/*.c`/`interpreter/src/*.c` sources the watch links (via
|
||||||
|
JNI/CMake, `android/app/src/main/jni/CMakeLists.txt`) - **the real,
|
||||||
|
full-precision `engine/third_party/astronomy/astronomy.c`, not the
|
||||||
|
watch's `lowprec_ephemeris.c` substitute**, since a phone has no
|
||||||
|
equivalent of Pebble's 64KB code+data+bss budget forcing that
|
||||||
|
substitution. Like the watch, the interpreter's real `.c` implementations
|
||||||
|
are linked in directly (`significance.c`/`narrative.c`/`guidance.c`),
|
||||||
|
not `json.c`/`reading_io.c` - there's no JSON pipe between separate
|
||||||
|
binaries here either, just one process computing and rendering its own
|
||||||
|
reading. Unlike the watch (which walks the `DailyReading`/
|
||||||
|
`DailyInterpretation` structs directly across the same C/UI boundary),
|
||||||
|
the JNI bridge (`android/app/src/main/jni/reading_json.c`, a structural
|
||||||
|
port of `interpreter/src/main.c`'s `interp_print_json()`) serializes one
|
||||||
|
complete reading to a JSON string per call - a single call, not
|
||||||
|
fine-grained per-field accessors, since the alternative would mean
|
||||||
|
either re-running real ephemeris trig per field access or pinning a live
|
||||||
|
native object across calls; Kotlin then decodes that JSON with
|
||||||
|
`kotlinx.serialization` rather than maintaining its own JNI accessor
|
||||||
|
surface. Every string in that JSON (titles, narrative, guidance,
|
||||||
|
position/card names and meanings) is already localized via `i18n_get()`,
|
||||||
|
so Kotlin never needs its own planet/aspect/sign display-name table -
|
||||||
|
only the `*_slug` fields exist for the UI's card-art drawable lookup.
|
||||||
|
Card art is regenerated by `android/scripts/gen_card_images.py`
|
||||||
|
(structurally a sibling of `watch/scripts/gen_card_images.py`, same slug
|
||||||
|
table) at 400px wide, lossy WebP - not the watch's 72px PNG, since a
|
||||||
|
phone has no hardware-decoder budget forcing that size, but 21MB of
|
||||||
|
source JPEGs would otherwise bloat the APK; config (birth data, gender,
|
||||||
|
language, notification settings - the same fields as `WatchConfig`) is
|
||||||
|
persisted via Jetpack Preferences DataStore instead of Pebble's
|
||||||
|
key-value persist API. The daily notification uses WorkManager (a
|
||||||
|
self-rescheduling one-shot chain, not `AlarmManager`, since a "roughly
|
||||||
|
this time daily" reminder doesn't need `AlarmManager`'s exact-alarm
|
||||||
|
permission burden) - the Android analog of `watch/src/c/notify.c`'s own
|
||||||
|
daily-wakeup contract.
|
||||||
|
|
||||||
|
**CI needs network access for this target specifically** - unlike the
|
||||||
|
CLI/Pebble build path above (deliberately zero network access needed
|
||||||
|
once `build-image` is pulled), Gradle/AGP/androidx dependency resolution
|
||||||
|
against Google's Maven + Maven Central happens at build time, every
|
||||||
|
build. This is a deliberate, accepted deviation from this image's
|
||||||
|
otherwise-offline build philosophy, not an oversight.
|
||||||
|
|
||||||
|
### Build image
|
||||||
|
|
||||||
|
`build-image/Dockerfile` pins the exact toolchain versions (Pebble
|
||||||
|
Tool, Pebble SDK core, Node.js, and the Android SDK/NDK) needed for
|
||||||
|
`make test`, `make package`, `make watchapp`, and `make android`, so
|
||||||
|
builds are reproducible independent of whatever happens to be installed
|
||||||
|
on a given machine. Three root-level scripts drive it, each reading
|
||||||
|
registry settings from a gitignored `registry.env` (copy
|
||||||
|
`registry.env.example` to create it - never commit the real file, since
|
||||||
|
it holds registry credentials) and the image tag from
|
||||||
|
`build-image/VERSION`:
|
||||||
|
|
||||||
|
- `build-image.sh` - builds and locally tags the image (`:VERSION` and
|
||||||
|
`:latest`), no push.
|
||||||
|
- `run-image.sh [VERSION]` - runs `make test package watchapp android`
|
||||||
|
inside the locally-built image against a live bind-mount of the repo,
|
||||||
|
wiping generated build artifacts first for CI parity. Use this to
|
||||||
|
validate a `build-image/Dockerfile` change before pushing the image
|
||||||
|
anywhere.
|
||||||
|
- `upload-image.sh` - logs in and pushes the image (`:VERSION` and
|
||||||
|
`:latest`) to the registry; `.gitea/workflows/build.yml` pulls
|
||||||
|
`:latest` to run its build job (see "Watch app packaging"/"Android app
|
||||||
|
packaging" above).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -200,6 +311,23 @@ more common (but less original) ordering; don't use it as a reference for
|
|||||||
the position enum order. Card and position text in `tarot_data.c` is
|
the position enum order. Card and position text in `tarot_data.c` is
|
||||||
condensed from Waite's own wording in Part III §3 (public domain).
|
condensed from Waite's own wording in Part III §3 (public domain).
|
||||||
|
|
||||||
|
**Gender** (`Gender` in `tarot.h`: `GENDER_UNSPECIFIED`/`GENDER_MALE`/
|
||||||
|
`GENDER_FEMALE`) is who the reading is for — it only affects the
|
||||||
|
pronouns in the 9 Celtic Cross position names/descriptions that have one
|
||||||
|
(Waite addresses the querent as "him" throughout; every other position
|
||||||
|
text in this codebase — card meanings, narrative, guidance — is already
|
||||||
|
pronoun-neutral or direct "you" address, so nothing else varies with
|
||||||
|
it). `DailyReading.gender` is set by `reading_generate()` the same way
|
||||||
|
`utc_moment` is, `deck-engine`'s `--gender male|female|unspecified`
|
||||||
|
flag/`dist/user.properties`' `gender=` field/the watch config page's
|
||||||
|
Gender field all feed it, and `reading_print_json`/`reading_load_json`
|
||||||
|
carry it across the `deck-engine` → `interpreter-cli` JSON boundary
|
||||||
|
(`tarot_gender_slug()`) the same way `date` does. `GENDER_UNSPECIFIED`
|
||||||
|
is the default and renders pronoun-neutral phrasing (singular "they" in
|
||||||
|
English; German rephrases around a noun like "die fragende Person"
|
||||||
|
rather than a pronoun, since German has no equivalent singular-they
|
||||||
|
construction in common use).
|
||||||
|
|
||||||
### Translations (`i18n.c`, `engine/i18n/*.lang`)
|
### Translations (`i18n.c`, `engine/i18n/*.lang`)
|
||||||
|
|
||||||
All display text (planet/sign/moon-phase/aspect names, tarot card names
|
All display text (planet/sign/moon-phase/aspect names, tarot card names
|
||||||
@@ -217,7 +345,12 @@ Keys are namespaced by the *slug* tables in `tarot_data.c`/`astro.c`
|
|||||||
`moonphase.<slug>`, `aspect.<slug>`), plus `ui.*` keys for `reading.c`'s
|
`moonphase.<slug>`, `aspect.<slug>`), plus `ui.*` keys for `reading.c`'s
|
||||||
own labels — deliberately keyed off separate slug strings rather than
|
own labels — deliberately keyed off separate slug strings rather than
|
||||||
the C enum names, so renaming an enumerator can never silently break a
|
the C enum names, so renaming an enumerator can never silently break a
|
||||||
`.lang` file. **To add a language**: copy `engine/i18n/en.lang` to
|
`.lang` file. `position.<slug>.name/desc` additionally take an optional
|
||||||
|
`.male`/`.female` suffix (e.g. `position.attitude.name.male`) for the
|
||||||
|
`Gender`-varying wording (see "Tarot" above); the unsuffixed key is
|
||||||
|
looked up first as the fallback for a missing gendered key, so a
|
||||||
|
position whose wording doesn't vary by gender only needs the one
|
||||||
|
unsuffixed key. **To add a language**: copy `engine/i18n/en.lang` to
|
||||||
`<code>.lang` in the same directory (keys must match exactly) and
|
`<code>.lang` in the same directory (keys must match exactly) and
|
||||||
translate the values — no source changes needed, `make` picks up any
|
translate the values — no source changes needed, `make` picks up any
|
||||||
`*.lang` file there automatically.
|
`*.lang` file there automatically.
|
||||||
@@ -444,10 +577,16 @@ built once and linked into *both* binaries.
|
|||||||
`VENDORED.md` for the pinned upstream commit.
|
`VENDORED.md` for the pinned upstream commit.
|
||||||
- Card/spread text in `tarot_data.c`/`engine/i18n/en.lang`: condensed
|
- Card/spread text in `tarot_data.c`/`engine/i18n/en.lang`: condensed
|
||||||
from A. E. Waite's *The Pictorial Key to the Tarot* (1911), public
|
from A. E. Waite's *The Pictorial Key to the Tarot* (1911), public
|
||||||
domain.
|
domain. The `GENDER_MALE` variant of the Celtic Cross position text
|
||||||
|
keeps Waite's own "him"/"his" wording verbatim; the
|
||||||
|
`GENDER_UNSPECIFIED`/`GENDER_FEMALE` variants are original paraphrases
|
||||||
|
written for this project, not Waite's own text (see "Tarot" above).
|
||||||
- `engine/i18n/de.lang`'s card/spread text is an original translation of
|
- `engine/i18n/de.lang`'s card/spread text is an original translation of
|
||||||
that same public-domain English text, made for this project — not
|
that same public-domain English text, made for this project — not
|
||||||
taken from any specific published German edition.
|
taken from any specific published German edition. Its `Gender`-varying
|
||||||
|
position text follows the same policy as the English original: `.male`
|
||||||
|
is a direct translation of Waite's own wording, `.female`/unsuffixed
|
||||||
|
are original paraphrases for this project.
|
||||||
- Transit narrative text in `interpreter/src/narrative.c`: condensed from
|
- Transit narrative text in `interpreter/src/narrative.c`: condensed from
|
||||||
Sepharial's *Transits and Planetary Periods* (1920, public domain —
|
Sepharial's *Transits and Planetary Periods* (1920, public domain —
|
||||||
`res/Transits_and_Planetary_Periods.pdf`), except the Moon and Pluto
|
`res/Transits_and_Planetary_Periods.pdf`), except the Moon and Pluto
|
||||||
|
|||||||
@@ -87,7 +87,17 @@ GUIDANCE_TEST_BIN := $(BUILD_DIR)/guidance_test
|
|||||||
ARCH := $(shell uname -m)
|
ARCH := $(shell uname -m)
|
||||||
PACKAGE_DIR := $(BUILD_DIR)/package
|
PACKAGE_DIR := $(BUILD_DIR)/package
|
||||||
|
|
||||||
.PHONY: all test clean images i18n scripts package
|
# ---- watch app (Pebble) ----
|
||||||
|
|
||||||
|
WATCH_DIR := watch
|
||||||
|
WATCH_PBW := $(WATCH_DIR)/build/watch.pbw
|
||||||
|
|
||||||
|
# ---- Android app ----
|
||||||
|
|
||||||
|
ANDROID_DIR := android
|
||||||
|
ANDROID_APK := $(ANDROID_DIR)/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
|
||||||
|
.PHONY: all test clean images i18n scripts package watchapp android
|
||||||
|
|
||||||
all: $(ENGINE_BINARY) $(INTERP_BINARY) images i18n scripts
|
all: $(ENGINE_BINARY) $(INTERP_BINARY) images i18n scripts
|
||||||
|
|
||||||
@@ -208,3 +218,63 @@ clean:
|
|||||||
rm -rf $(BUILD_DIR) $(ENGINE_BINARY) $(INTERP_BINARY) $(DIST_IMG_DIR) $(DIST_I18N_DIR) \
|
rm -rf $(BUILD_DIR) $(ENGINE_BINARY) $(INTERP_BINARY) $(DIST_IMG_DIR) $(DIST_I18N_DIR) \
|
||||||
$(DIST_DIR)/run-engine.sh $(DIST_DIR)/run-interpreter.sh
|
$(DIST_DIR)/run-engine.sh $(DIST_DIR)/run-interpreter.sh
|
||||||
# user.properties holds the user's own data - never removed by clean.
|
# user.properties holds the user's own data - never removed by clean.
|
||||||
|
|
||||||
|
# Builds the Pebble watchapp (`pebble build` in watch/, via its own
|
||||||
|
# wscript/pebble_sdk waf tooling - not a plain C toolchain, so this is a
|
||||||
|
# separate target from `all` rather than one of its prerequisites) and
|
||||||
|
# copies the resulting .pbw into dist/, versioned the same way `package`
|
||||||
|
# above versions the CLI tarball (current git tag, or
|
||||||
|
# `make watchapp VERSION=1.2.3` to override). Requires the Pebble SDK
|
||||||
|
# (`pebble` on PATH, plus Python 3 + Pillow for watch/scripts/*.py - see
|
||||||
|
# watch/wscript) - this is NOT checked by the root `make`/`make test`,
|
||||||
|
# so a machine without the Pebble SDK can still build/test the CLI.
|
||||||
|
watchapp:
|
||||||
|
cd $(WATCH_DIR) && pebble build
|
||||||
|
@mkdir -p $(DIST_DIR)
|
||||||
|
@V="$(VERSION)"; \
|
||||||
|
if [ -z "$$V" ]; then \
|
||||||
|
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
|
||||||
|
V=$${TAG#v}; \
|
||||||
|
else \
|
||||||
|
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
|
||||||
|
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
|
||||||
|
fi; \
|
||||||
|
fi; \
|
||||||
|
case "$$V" in \
|
||||||
|
[0-9]*.[0-9]*.[0-9]*) ;; \
|
||||||
|
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
|
||||||
|
esac; \
|
||||||
|
PBW_NAME="deck-in-a-dash-$$V.pbw"; \
|
||||||
|
cp $(WATCH_PBW) "$(DIST_DIR)/$$PBW_NAME"; \
|
||||||
|
echo "Wrote $(DIST_DIR)/$$PBW_NAME"
|
||||||
|
|
||||||
|
# Builds the Android app (`./gradlew assembleDebug` in android/, via its
|
||||||
|
# own Gradle/AGP/NDK toolchain - not a plain C toolchain, so this is a
|
||||||
|
# separate target from `all` rather than one of its prerequisites) and
|
||||||
|
# copies the resulting debug APK into dist/, versioned the same way
|
||||||
|
# `package`/`watchapp` above version their own artifacts (current git
|
||||||
|
# tag, or `make android VERSION=1.2.3` to override). Ships the
|
||||||
|
# auto-generated debug keystore, not a release signing config - see the
|
||||||
|
# root CLAUDE.md's Android section for why. Requires the Android SDK/NDK
|
||||||
|
# (ANDROID_HOME/ANDROID_NDK_HOME set, see android/README.md) - this is
|
||||||
|
# NOT checked by the root `make`/`make test`, so a machine without them
|
||||||
|
# can still build/test the CLI (and, separately, the watch app).
|
||||||
|
android:
|
||||||
|
cd $(ANDROID_DIR) && ./gradlew assembleDebug
|
||||||
|
@mkdir -p $(DIST_DIR)
|
||||||
|
@V="$(VERSION)"; \
|
||||||
|
if [ -z "$$V" ]; then \
|
||||||
|
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
|
||||||
|
V=$${TAG#v}; \
|
||||||
|
else \
|
||||||
|
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
|
||||||
|
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
|
||||||
|
fi; \
|
||||||
|
fi; \
|
||||||
|
case "$$V" in \
|
||||||
|
[0-9]*.[0-9]*.[0-9]*) ;; \
|
||||||
|
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
|
||||||
|
esac; \
|
||||||
|
APK_NAME="deck-in-a-dash-$$V.apk"; \
|
||||||
|
cp $(ANDROID_APK) "$(DIST_DIR)/$$APK_NAME"; \
|
||||||
|
echo "Wrote $(DIST_DIR)/$$APK_NAME"
|
||||||
|
|||||||
@@ -52,7 +52,26 @@ interpreter/ Separate module + binary (dist/interpreter-cli)
|
|||||||
deck-engine's --format json output. Supports
|
deck-engine's --format json output. Supports
|
||||||
--format text|html|json and --lang en|de for
|
--format text|html|json and --lang en|de for
|
||||||
its own output too (see CLAUDE.md).
|
its own output too (see CLAUDE.md).
|
||||||
|
watch/ The actual Pebble watchapp - links engine/src/
|
||||||
|
and interpreter/src/ in directly (see CLAUDE.md's
|
||||||
|
"Portability to the watch"), plus its own
|
||||||
|
persistence, reading recompute, UI, daily
|
||||||
|
notification, and config page. Built separately
|
||||||
|
from the rest of this repo (`make watchapp`,
|
||||||
|
below) - requires the Pebble SDK.
|
||||||
|
android/ A standalone Android app (independent of the
|
||||||
|
watch app - neither requires the other) with the
|
||||||
|
same daily reading + config page, Jetpack Compose
|
||||||
|
UI, links engine/src/ and interpreter/src/ via
|
||||||
|
JNI/CMake (see CLAUDE.md's "Android app
|
||||||
|
packaging"). Built separately (`make android`,
|
||||||
|
below) - requires the Android SDK/NDK.
|
||||||
docs/ Architecture/dataflow diagrams, input/output format reference.
|
docs/ Architecture/dataflow diagrams, input/output format reference.
|
||||||
|
build-image/ Dockerfile for the reproducible build environment
|
||||||
|
(C toolchain + Pebble SDK + Android SDK/NDK) used
|
||||||
|
by CI and by the build-image.sh/run-image.sh/
|
||||||
|
upload-image.sh scripts at the repo root (see
|
||||||
|
"Building" below).
|
||||||
Makefile Single root Makefile, builds both engine/ and
|
Makefile Single root Makefile, builds both engine/ and
|
||||||
interpreter/, and packages a release (`make package`).
|
interpreter/, and packages a release (`make package`).
|
||||||
```
|
```
|
||||||
@@ -78,6 +97,39 @@ make package # -> dist/deck-in-a-dash-<version>-linux-<arch>.tar.gz, a
|
|||||||
|
|
||||||
Requires only a C99 compiler and `make` — no other dependencies.
|
Requires only a C99 compiler and `make` — no other dependencies.
|
||||||
|
|
||||||
|
**Building the watch app itself** (`watch/`, the actual Pebble app)
|
||||||
|
needs the separate Pebble SDK (`pebble` on `PATH`), so it's a separate
|
||||||
|
target, not part of `make`/`make test`/`make package` above:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make watchapp # -> dist/deck-in-a-dash-<version>.pbw, same version
|
||||||
|
# resolution as `make package`
|
||||||
|
```
|
||||||
|
|
||||||
|
**Building the Android app** (`android/`, a standalone app independent
|
||||||
|
of the watch app) needs the separate Android SDK/NDK
|
||||||
|
(`ANDROID_HOME`/`ANDROID_NDK_HOME` set - see `android/README.md`), so
|
||||||
|
it's likewise a separate target:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make android # -> dist/deck-in-a-dash-<version>.apk, same version
|
||||||
|
# resolution as `make package`
|
||||||
|
```
|
||||||
|
|
||||||
|
**CI runs `make watchapp` too** — `.gitea/workflows/build.yml` builds
|
||||||
|
and tests everything inside a container built from `build-image/`,
|
||||||
|
which bundles the Pebble SDK alongside the C toolchain so the runner
|
||||||
|
itself doesn't need either installed. To build/test that image
|
||||||
|
yourself, copy `registry.env.example` to `registry.env` and fill in
|
||||||
|
your registry credentials, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-image.sh # build and locally tag the image
|
||||||
|
./run-image.sh # run `make test package watchapp` inside it, against
|
||||||
|
# this checkout, for a CI-equivalent local build
|
||||||
|
./upload-image.sh # push the image so CI can pull it
|
||||||
|
```
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
**Daily use**, via `dist/run-engine.sh`: pulls today's date and the
|
**Daily use**, via `dist/run-engine.sh`: pulls today's date and the
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Deck in a Dash — Android app
|
||||||
|
|
||||||
|
A standalone Android app showing the same daily tarot (Celtic Cross) +
|
||||||
|
astrology reading as the Pebble watch app (`../watch/`), with a burger
|
||||||
|
menu leading to a config page. Independent of the watch app - neither
|
||||||
|
requires the other to be installed. See the root
|
||||||
|
[`CLAUDE.md`](../CLAUDE.md)'s "Android app" section for the full
|
||||||
|
architecture (JNI-linked engine/interpreter, JSON bridge schema,
|
||||||
|
WorkManager notification, card art pipeline).
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
|
||||||
|
- A JDK (17+; the project's own `compileOptions`/toolchain target Java
|
||||||
|
17). `JAVA_HOME` should point at it, or a `java` matching that version
|
||||||
|
must be first on `PATH`.
|
||||||
|
- The Android SDK, with `ANDROID_HOME` (or `ANDROID_SDK_ROOT`) set -
|
||||||
|
needs at minimum `platforms;android-36`, `build-tools;36.0.0`,
|
||||||
|
`ndk;30.0.14904198`, and `cmake;3.22.1` installed (see
|
||||||
|
`app/build.gradle.kts`'s `compileSdk`/`ndkVersion` and
|
||||||
|
`app/src/main/jni/CMakeLists.txt`'s `cmake_minimum_required` for the
|
||||||
|
exact versions this project pins - `../build-image/Dockerfile`
|
||||||
|
installs the same ones for CI).
|
||||||
|
- `ANDROID_NDK_HOME` set to that installed NDK's directory.
|
||||||
|
- Python 3 + Pillow (`pip install Pillow`) - needed by
|
||||||
|
`scripts/gen_card_images.py`/`gen_launcher_icon.py`, which Gradle's
|
||||||
|
`preBuild` task runs automatically (see `app/build.gradle.kts`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, from the repo root: `make android`, which wraps the above and
|
||||||
|
copies the APK into `dist/deck-in-a-dash-<version>.pbw`'s sibling,
|
||||||
|
`dist/deck-in-a-dash-<version>.apk` (same version resolution as `make
|
||||||
|
package`/`make watchapp` - see the root `Makefile`).
|
||||||
|
|
||||||
|
The Gradle wrapper (`./gradlew`) resolves its own pinned Gradle version
|
||||||
|
on first run - no separate Gradle install is needed.
|
||||||
|
|
||||||
|
Ships the Android auto-generated debug keystore, not a release-signed
|
||||||
|
build - see the root `CLAUDE.md`'s Android section for why.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "de.ladkau.deckinadash"
|
||||||
|
compileSdk = 36
|
||||||
|
ndkVersion = "30.0.14904198"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "de.ladkau.deckinadash"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.0.0-dev"
|
||||||
|
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
path = file("src/main/jni/CMakeLists.txt")
|
||||||
|
version = "3.22.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerates app/src/main/res/drawable-nodpi/<slug>.webp from
|
||||||
|
// ../../res/img/*.jpeg (see ../../scripts/gen_card_images.py) and syncs
|
||||||
|
// engine/i18n/*.lang into app/src/main/assets/i18n/ - both are gitignored
|
||||||
|
// generated output, so a fresh checkout needs them produced before any
|
||||||
|
// resource-merge/asset-packaging task runs. Mirrors watch/wscript's own
|
||||||
|
// "generate before the SDK tooling packs resources" fix - see that
|
||||||
|
// file's own comment for the ordering bug this exact pattern avoids.
|
||||||
|
val generateCardArt = tasks.register<Exec>("generateCardArt") {
|
||||||
|
workingDir = rootDir
|
||||||
|
commandLine("python3", "scripts/gen_card_images.py")
|
||||||
|
}
|
||||||
|
|
||||||
|
val generateLauncherIcon = tasks.register<Exec>("generateLauncherIcon") {
|
||||||
|
workingDir = rootDir
|
||||||
|
commandLine("python3", "scripts/gen_launcher_icon.py")
|
||||||
|
}
|
||||||
|
|
||||||
|
val syncI18n = tasks.register<Copy>("syncI18n") {
|
||||||
|
from("${rootDir}/../engine/i18n")
|
||||||
|
include("*.lang")
|
||||||
|
into("src/main/assets/i18n")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("preBuild") {
|
||||||
|
dependsOn(generateCardArt, generateLauncherIcon, syncI18n)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.ui.graphics)
|
||||||
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.material.icons.core)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
# kotlinx.serialization needs its generated serializer classes kept.
|
||||||
|
-keepattributes *Annotation*, InnerClasses
|
||||||
|
-dontnote kotlinx.serialization.AnnotationsKt
|
||||||
|
-keepclasseswithmembers class de.ladkau.deckinadash.data.** {
|
||||||
|
*** Companion;
|
||||||
|
}
|
||||||
|
-keepclasseswithmembers class de.ladkau.deckinadash.data.** {
|
||||||
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".DeckInADashApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.DeckInADash">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.DeckInADash">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package de.ladkau.deckinadash
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import de.ladkau.deckinadash.data.ConfigRepository
|
||||||
|
import de.ladkau.deckinadash.notify.NotificationScheduler
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies assets/i18n's .lang files (synced at build time from engine/i18n/ -
|
||||||
|
* see app/build.gradle.kts's syncI18n task) into filesDir/i18n/ once
|
||||||
|
* per process start, since i18n_load() (linked into the native library
|
||||||
|
* unchanged, see reading_json.c) needs real filesystem paths, not APK
|
||||||
|
* assets. Also reschedules the daily notification on launch - one of
|
||||||
|
* the two required call sites for NotificationScheduler.reschedule(),
|
||||||
|
* see its own doc comment for the other (every config save).
|
||||||
|
*/
|
||||||
|
class DeckInADashApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
syncI18nAssets()
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
val config = ConfigRepository(this@DeckInADashApplication).config.first()
|
||||||
|
if (config.configured) {
|
||||||
|
NotificationScheduler.reschedule(this@DeckInADashApplication, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncI18nAssets() {
|
||||||
|
val outDir = File(filesDir, "i18n").apply { mkdirs() }
|
||||||
|
val files = assets.list("i18n") ?: return
|
||||||
|
for (name in files) {
|
||||||
|
assets.open("i18n/$name").use { input ->
|
||||||
|
File(outDir, name).outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.ladkau.deckinadash
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import de.ladkau.deckinadash.ui.DeckInADashApp
|
||||||
|
import de.ladkau.deckinadash.ui.theme.DeckInADashTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
DeckInADashTheme {
|
||||||
|
DeckInADashApp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package de.ladkau.deckinadash.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
private val Context.configDataStore: DataStore<Preferences> by preferencesDataStore(name = "deck_config")
|
||||||
|
|
||||||
|
/** Preferences DataStore-backed persistence for [DeckConfig] - see that
|
||||||
|
* class's own doc comment for why DataStore over SharedPreferences/Proto
|
||||||
|
* DataStore. */
|
||||||
|
class ConfigRepository(private val context: Context) {
|
||||||
|
private object Keys {
|
||||||
|
val CONFIGURED = booleanPreferencesKey("configured")
|
||||||
|
val BIRTH_YEAR = intPreferencesKey("birth_year")
|
||||||
|
val BIRTH_MONTH = intPreferencesKey("birth_month")
|
||||||
|
val BIRTH_DAY = intPreferencesKey("birth_day")
|
||||||
|
val BIRTH_HOUR = intPreferencesKey("birth_hour")
|
||||||
|
val BIRTH_MINUTE = intPreferencesKey("birth_minute")
|
||||||
|
val UTC_OFFSET_HOURS = doublePreferencesKey("utc_offset_hours")
|
||||||
|
val LATITUDE = doublePreferencesKey("latitude")
|
||||||
|
val LONGITUDE = doublePreferencesKey("longitude")
|
||||||
|
val GENDER = stringPreferencesKey("gender")
|
||||||
|
val LANG = stringPreferencesKey("lang")
|
||||||
|
val NOTIFY_ENABLED = booleanPreferencesKey("notify_enabled")
|
||||||
|
val NOTIFY_HOUR = intPreferencesKey("notify_hour")
|
||||||
|
val NOTIFY_MINUTE = intPreferencesKey("notify_minute")
|
||||||
|
}
|
||||||
|
|
||||||
|
val config: Flow<DeckConfig> = context.configDataStore.data.map { prefs ->
|
||||||
|
DeckConfig(
|
||||||
|
configured = prefs[Keys.CONFIGURED] ?: false,
|
||||||
|
birthYear = prefs[Keys.BIRTH_YEAR] ?: 0,
|
||||||
|
birthMonth = prefs[Keys.BIRTH_MONTH] ?: 0,
|
||||||
|
birthDay = prefs[Keys.BIRTH_DAY] ?: 0,
|
||||||
|
birthHour = prefs[Keys.BIRTH_HOUR] ?: 0,
|
||||||
|
birthMinute = prefs[Keys.BIRTH_MINUTE] ?: 0,
|
||||||
|
utcOffsetHours = prefs[Keys.UTC_OFFSET_HOURS] ?: 0.0,
|
||||||
|
latitude = prefs[Keys.LATITUDE] ?: 0.0,
|
||||||
|
longitude = prefs[Keys.LONGITUDE] ?: 0.0,
|
||||||
|
gender = prefs[Keys.GENDER]?.let { Gender.valueOf(it) } ?: Gender.UNSPECIFIED,
|
||||||
|
lang = prefs[Keys.LANG] ?: "en",
|
||||||
|
notifyEnabled = prefs[Keys.NOTIFY_ENABLED] ?: false,
|
||||||
|
notifyHour = prefs[Keys.NOTIFY_HOUR] ?: 8,
|
||||||
|
notifyMinute = prefs[Keys.NOTIFY_MINUTE] ?: 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun save(config: DeckConfig) {
|
||||||
|
context.configDataStore.edit { prefs ->
|
||||||
|
prefs[Keys.CONFIGURED] = true
|
||||||
|
prefs[Keys.BIRTH_YEAR] = config.birthYear
|
||||||
|
prefs[Keys.BIRTH_MONTH] = config.birthMonth
|
||||||
|
prefs[Keys.BIRTH_DAY] = config.birthDay
|
||||||
|
prefs[Keys.BIRTH_HOUR] = config.birthHour
|
||||||
|
prefs[Keys.BIRTH_MINUTE] = config.birthMinute
|
||||||
|
prefs[Keys.UTC_OFFSET_HOURS] = config.utcOffsetHours
|
||||||
|
prefs[Keys.LATITUDE] = config.latitude
|
||||||
|
prefs[Keys.LONGITUDE] = config.longitude
|
||||||
|
prefs[Keys.GENDER] = config.gender.name
|
||||||
|
prefs[Keys.LANG] = config.lang
|
||||||
|
prefs[Keys.NOTIFY_ENABLED] = config.notifyEnabled
|
||||||
|
prefs[Keys.NOTIFY_HOUR] = config.notifyHour
|
||||||
|
prefs[Keys.NOTIFY_MINUTE] = config.notifyMinute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package de.ladkau.deckinadash.data
|
||||||
|
|
||||||
|
/** Mirrors engine/src/tarot.h's Gender enum - ordinal must match. */
|
||||||
|
enum class Gender {
|
||||||
|
UNSPECIFIED,
|
||||||
|
MALE,
|
||||||
|
FEMALE,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the app needs that isn't computed fresh each day: birth
|
||||||
|
* data, display language, and daily notification settings. Mirrors
|
||||||
|
* watch/src/c/config.h's WatchConfig field-for-field - same data, same
|
||||||
|
* defaults, persisted here via Preferences DataStore (see
|
||||||
|
* ConfigRepository) instead of Pebble's key-value persist API.
|
||||||
|
*/
|
||||||
|
data class DeckConfig(
|
||||||
|
val configured: Boolean = false,
|
||||||
|
val birthYear: Int = 0,
|
||||||
|
val birthMonth: Int = 0,
|
||||||
|
val birthDay: Int = 0,
|
||||||
|
val birthHour: Int = 0,
|
||||||
|
val birthMinute: Int = 0,
|
||||||
|
val utcOffsetHours: Double = 0.0,
|
||||||
|
val latitude: Double = 0.0,
|
||||||
|
val longitude: Double = 0.0,
|
||||||
|
val gender: Gender = Gender.UNSPECIFIED,
|
||||||
|
val lang: String = "en",
|
||||||
|
val notifyEnabled: Boolean = false,
|
||||||
|
val notifyHour: Int = 8,
|
||||||
|
val notifyMinute: Int = 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package de.ladkau.deckinadash.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the JSON schema app/src/main/jni/reading_json.c emits - see
|
||||||
|
* that file's own header comment. Deserialized with a snake_case naming
|
||||||
|
* strategy (see ReadingRepository's Json instance), so these property
|
||||||
|
* names stay idiomatic Kotlin camelCase without needing @SerialName on
|
||||||
|
* every field.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ReadingDto(
|
||||||
|
val date: String,
|
||||||
|
val gender: String,
|
||||||
|
val daySignificance: DaySignificanceDto,
|
||||||
|
val transits: List<TransitDto>,
|
||||||
|
val guidance: String,
|
||||||
|
val spread: List<SpreadPositionDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DaySignificanceDto(
|
||||||
|
val levelSlug: String,
|
||||||
|
val levelLabel: String,
|
||||||
|
val rank: Int,
|
||||||
|
val count: Int,
|
||||||
|
val deservesFraming: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TransitDto(
|
||||||
|
val title: String,
|
||||||
|
val transitingPlanetSlug: String,
|
||||||
|
val natalPlanetSlug: String,
|
||||||
|
val aspectSlug: String,
|
||||||
|
val orb: Double,
|
||||||
|
val score: Double,
|
||||||
|
val narrative: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SpreadPositionDto(
|
||||||
|
val positionSlug: String,
|
||||||
|
val positionName: String,
|
||||||
|
val positionDescription: String,
|
||||||
|
val cardSlug: String,
|
||||||
|
val cardName: String,
|
||||||
|
val reversed: Boolean,
|
||||||
|
val cardMeaning: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package de.ladkau.deckinadash.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import de.ladkau.deckinadash.nativebridge.DeckNative
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonNamingStrategy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a [ReadingDto] via [DeckNative], the single entry point both
|
||||||
|
* the reading screen (ReadingViewModel) and the daily notification
|
||||||
|
* (ReadingNotificationWorker) call - see NotificationScheduler's own
|
||||||
|
* comment on why the notification must reuse this rather than
|
||||||
|
* duplicating reading logic.
|
||||||
|
*/
|
||||||
|
class ReadingRepository(private val context: Context) {
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directory DeckInADashApplication.onCreate() synced engine/i18n's .lang files into. */
|
||||||
|
private val i18nDir: String
|
||||||
|
get() = "${context.filesDir}/i18n"
|
||||||
|
|
||||||
|
fun compute(config: DeckConfig, utcMomentEpochSeconds: Long): ReadingDto {
|
||||||
|
val jsonText = DeckNative.generateReadingJson(
|
||||||
|
birthYear = config.birthYear,
|
||||||
|
birthMonth = config.birthMonth,
|
||||||
|
birthDay = config.birthDay,
|
||||||
|
birthHour = config.birthHour,
|
||||||
|
birthMinute = config.birthMinute,
|
||||||
|
utcOffsetHours = config.utcOffsetHours,
|
||||||
|
latitude = config.latitude,
|
||||||
|
longitude = config.longitude,
|
||||||
|
gender = config.gender.ordinal,
|
||||||
|
utcMomentEpochSeconds = utcMomentEpochSeconds,
|
||||||
|
i18nDir = i18nDir,
|
||||||
|
lang = config.lang,
|
||||||
|
)
|
||||||
|
return json.decodeFromString(ReadingDto.serializer(), jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.ladkau.deckinadash.nativebridge
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JNI bridge to the native `deckengine` shared library (built from
|
||||||
|
* app/src/main/jni/CMakeLists.txt, which links the same engine +
|
||||||
|
* interpreter C sources the watch app links - see that CMakeLists.txt's
|
||||||
|
* own comment). One call returns one complete JSON snapshot of a day's
|
||||||
|
* reading; see app/src/main/jni/reading_json.h for the exact schema and
|
||||||
|
* why this is a single call rather than many fine-grained accessors.
|
||||||
|
*/
|
||||||
|
object DeckNative {
|
||||||
|
init {
|
||||||
|
System.loadLibrary("deckengine")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param gender 0 = unspecified, 1 = male, 2 = female - matches
|
||||||
|
* engine/src/tarot.h's Gender enum order.
|
||||||
|
* @param i18nDir directory containing `<lang>.lang` files (see
|
||||||
|
* engine/i18n/), synced from assets by DeckInADashApp.onCreate().
|
||||||
|
* @throws IllegalStateException if native JSON generation fails.
|
||||||
|
*/
|
||||||
|
external fun generateReadingJson(
|
||||||
|
birthYear: Int,
|
||||||
|
birthMonth: Int,
|
||||||
|
birthDay: Int,
|
||||||
|
birthHour: Int,
|
||||||
|
birthMinute: Int,
|
||||||
|
utcOffsetHours: Double,
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
gender: Int,
|
||||||
|
utcMomentEpochSeconds: Long,
|
||||||
|
i18nDir: String,
|
||||||
|
lang: String,
|
||||||
|
): String
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.ladkau.deckinadash.notify
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import de.ladkau.deckinadash.data.DeckConfig
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WorkManager analog of watch/src/c/notify.c's notify_reschedule(): (re)schedules
|
||||||
|
* a one-shot wakeup for the next occurrence of `config.notifyHour:notifyMinute`
|
||||||
|
* in the device's local time (today if not yet passed, tomorrow otherwise),
|
||||||
|
* or only cancels if notifications are disabled. See ReadingNotificationWorker
|
||||||
|
* for the self-rescheduling chain this kicks off.
|
||||||
|
*
|
||||||
|
* Call once per app launch (DeckInADashApplication.onCreate()) and on every
|
||||||
|
* config save (ConfigViewModel.save()) - same two call sites notify_reschedule()
|
||||||
|
* itself requires, for the same reason (settings may have changed).
|
||||||
|
*/
|
||||||
|
object NotificationScheduler {
|
||||||
|
private const val WORK_NAME = "daily_reading_notification"
|
||||||
|
|
||||||
|
fun reschedule(context: Context, config: DeckConfig) {
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
if (!config.notifyEnabled) {
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val delayMillis = millisUntilNext(config.notifyHour, config.notifyMinute)
|
||||||
|
val request = OneTimeWorkRequestBuilder<ReadingNotificationWorker>()
|
||||||
|
.setInitialDelay(delayMillis, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
// REPLACE is the WorkManager analog of notify_reschedule()'s own
|
||||||
|
// cancel-then-reschedule - guarantees no duplicate chains.
|
||||||
|
workManager.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun millisUntilNext(hour: Int, minute: Int): Long {
|
||||||
|
val now = Calendar.getInstance()
|
||||||
|
val target = (now.clone() as Calendar).apply {
|
||||||
|
set(Calendar.HOUR_OF_DAY, hour)
|
||||||
|
set(Calendar.MINUTE, minute)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}
|
||||||
|
if (!target.after(now)) {
|
||||||
|
target.add(Calendar.DAY_OF_YEAR, 1)
|
||||||
|
}
|
||||||
|
return target.timeInMillis - now.timeInMillis
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package de.ladkau.deckinadash.notify
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import de.ladkau.deckinadash.MainActivity
|
||||||
|
import de.ladkau.deckinadash.R
|
||||||
|
import de.ladkau.deckinadash.data.ConfigRepository
|
||||||
|
import de.ladkau.deckinadash.data.ReadingDto
|
||||||
|
import de.ladkau.deckinadash.data.ReadingRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
private const val CHANNEL_ID = "daily_reading"
|
||||||
|
private const val NOTIFICATION_ID = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires once (scheduled by NotificationScheduler.reschedule()), computes
|
||||||
|
* today's reading via the same ReadingRepository the UI uses (no
|
||||||
|
* duplicated reading logic - see that class's own comment), posts a
|
||||||
|
* "quick digest" notification from the day's significance + top
|
||||||
|
* transit (the same lead-in watch/src/c/ui_report_window.c's own
|
||||||
|
* comment describes the watch's wakeup-vibrate leading into), then
|
||||||
|
* reschedules itself for tomorrow - WorkManager has no "repeat forever
|
||||||
|
* at an exact wall-clock time" primitive, so this self-chaining step is
|
||||||
|
* how notify_reschedule()'s own daily-repeat contract is reproduced.
|
||||||
|
*/
|
||||||
|
class ReadingNotificationWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val configRepository = ConfigRepository(applicationContext)
|
||||||
|
val config = configRepository.config.first()
|
||||||
|
|
||||||
|
// Mirrors notify_reschedule()'s own "if disabled, only cancel" branch -
|
||||||
|
// a defensive no-op, since reschedule() should already have cancelled
|
||||||
|
// this work item when notifications were turned off.
|
||||||
|
if (!config.notifyEnabled || !config.configured) {
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
val reading = try {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
ReadingRepository(applicationContext).compute(config, Instant.now().epochSecond)
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
NotificationScheduler.reschedule(applicationContext, config)
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
|
||||||
|
postNotification(reading)
|
||||||
|
NotificationScheduler.reschedule(applicationContext, config)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postNotification(reading: ReadingDto) {
|
||||||
|
ensureChannel()
|
||||||
|
|
||||||
|
val body = reading.transits.firstOrNull()?.let { "${it.title}. ${it.narrative}" }
|
||||||
|
?: reading.guidance
|
||||||
|
|
||||||
|
val openAppIntent = PendingIntent.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
Intent(applicationContext, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(
|
||||||
|
applicationContext.getString(R.string.notification_title, reading.daySignificance.levelLabel),
|
||||||
|
)
|
||||||
|
.setContentText(body)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(openAppIntent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
if (ActivityCompat.checkSelfPermission(
|
||||||
|
applicationContext,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
NotificationManagerCompat.from(applicationContext).notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
applicationContext.getString(R.string.notification_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
|
).apply {
|
||||||
|
description = applicationContext.getString(R.string.notification_channel_description)
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.ladkau.deckinadash.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a card slug (e.g. "fool", "wheel_of_fortune" - the same
|
||||||
|
* strings engine/src/tarot_data.c's tarot_card_slug() returns) to its
|
||||||
|
* generated drawable resource, produced by ../../scripts/gen_card_images.py
|
||||||
|
* into res/drawable-nodpi/<slug>.webp at build time - see that script's
|
||||||
|
* own comment. Slugs are already valid Android resource-name identifiers,
|
||||||
|
* so no separate mapping table is needed, only a runtime resource lookup
|
||||||
|
* (the resource ID isn't known at compile time from this module, since
|
||||||
|
* the drawables are generated, not committed).
|
||||||
|
*/
|
||||||
|
@DrawableRes
|
||||||
|
fun cardDrawableRes(context: Context, cardSlug: String): Int {
|
||||||
|
val id = context.resources.getIdentifier(cardSlug, "drawable", context.packageName)
|
||||||
|
return if (id != 0) id else android.R.drawable.ic_menu_report_image
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package de.ladkau.deckinadash.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import de.ladkau.deckinadash.R
|
||||||
|
import de.ladkau.deckinadash.ui.about.AboutScreen
|
||||||
|
import de.ladkau.deckinadash.ui.config.ConfigScreen
|
||||||
|
import de.ladkau.deckinadash.ui.reading.ReadingScreen
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private object Destinations {
|
||||||
|
const val READING = "reading"
|
||||||
|
const val SETTINGS = "settings"
|
||||||
|
const val ABOUT = "about"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Burger menu = ModalNavigationDrawer, leading to Reading/Settings/About -
|
||||||
|
* the app's single navigation host, per the plan's UI decision. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DeckInADashApp() {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
drawerContent = {
|
||||||
|
ModalDrawerSheet {
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text(stringResource(R.string.nav_reading)) },
|
||||||
|
selected = currentRoute == Destinations.READING,
|
||||||
|
onClick = { navigateTo(navController, Destinations.READING); scope.launch { drawerState.close() } },
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text(stringResource(R.string.nav_settings)) },
|
||||||
|
selected = currentRoute == Destinations.SETTINGS,
|
||||||
|
onClick = { navigateTo(navController, Destinations.SETTINGS); scope.launch { drawerState.close() } },
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text(stringResource(R.string.nav_about)) },
|
||||||
|
selected = currentRoute == Destinations.ABOUT,
|
||||||
|
onClick = { navigateTo(navController, Destinations.ABOUT); scope.launch { drawerState.close() } },
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.app_name)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||||
|
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_settings))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = Destinations.READING,
|
||||||
|
modifier = Modifier.padding(innerPadding),
|
||||||
|
) {
|
||||||
|
composable(Destinations.READING) {
|
||||||
|
ReadingScreen(onNavigateToSettings = { navigateTo(navController, Destinations.SETTINGS) })
|
||||||
|
}
|
||||||
|
composable(Destinations.SETTINGS) { ConfigScreen() }
|
||||||
|
composable(Destinations.ABOUT) { AboutScreen() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateTo(navController: NavHostController, route: String) {
|
||||||
|
navController.navigate(route) {
|
||||||
|
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package de.ladkau.deckinadash.ui.about
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.ladkau.deckinadash.R
|
||||||
|
|
||||||
|
/** Static Waite/Sepharial public-domain attribution, carried forward
|
||||||
|
* from the root CLAUDE.md's own "Licensing" section, since this app's
|
||||||
|
* reading screen displays that same card/narrative text verbatim. */
|
||||||
|
@Composable
|
||||||
|
fun AboutScreen(modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_body),
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package de.ladkau.deckinadash.ui.config
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import de.ladkau.deckinadash.R
|
||||||
|
import de.ladkau.deckinadash.data.Gender
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ConfigScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ConfigViewModel = viewModel(),
|
||||||
|
) {
|
||||||
|
val config by viewModel.config.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
var showBirthTimePicker by remember { mutableStateOf(false) }
|
||||||
|
var showNotifyTimePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
viewModel.update { it.copy(notifyEnabled = granted) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.config_birth_date), style = MaterialTheme.typography.titleSmall)
|
||||||
|
Button(onClick = { showDatePicker = true }) {
|
||||||
|
val label = if (config.birthYear > 0) {
|
||||||
|
"%04d-%02d-%02d".format(config.birthYear, config.birthMonth, config.birthDay)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.config_birth_date)
|
||||||
|
}
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(stringResource(R.string.config_birth_time), style = MaterialTheme.typography.titleSmall)
|
||||||
|
Button(onClick = { showBirthTimePicker = true }) {
|
||||||
|
Text("%02d:%02d".format(config.birthHour, config.birthMinute))
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = config.utcOffsetHours.toString(),
|
||||||
|
onValueChange = { text ->
|
||||||
|
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(utcOffsetHours = value) } }
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.config_utc_offset)) },
|
||||||
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = config.latitude.toString(),
|
||||||
|
onValueChange = { text ->
|
||||||
|
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(latitude = value) } }
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.config_latitude)) },
|
||||||
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = config.longitude.toString(),
|
||||||
|
onValueChange = { text ->
|
||||||
|
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(longitude = value) } }
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.config_longitude)) },
|
||||||
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(stringResource(R.string.config_gender), style = MaterialTheme.typography.titleSmall)
|
||||||
|
val genderOptions = listOf(
|
||||||
|
Gender.UNSPECIFIED to stringResource(R.string.config_gender_unspecified),
|
||||||
|
Gender.MALE to stringResource(R.string.config_gender_male),
|
||||||
|
Gender.FEMALE to stringResource(R.string.config_gender_female),
|
||||||
|
)
|
||||||
|
SingleChoiceSegmentedButtonRow {
|
||||||
|
genderOptions.forEachIndexed { index, (gender, label) ->
|
||||||
|
SegmentedButton(
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index = index, count = genderOptions.size),
|
||||||
|
selected = config.gender == gender,
|
||||||
|
onClick = { viewModel.update { it.copy(gender = gender) } },
|
||||||
|
) { Text(label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LanguageDropdown(
|
||||||
|
selected = config.lang,
|
||||||
|
onSelected = { lang -> viewModel.update { it.copy(lang = lang) } },
|
||||||
|
)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
Text(stringResource(R.string.config_notify_enabled), style = MaterialTheme.typography.titleSmall)
|
||||||
|
Switch(
|
||||||
|
checked = config.notifyEnabled,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
if (enabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
} else {
|
||||||
|
viewModel.update { it.copy(notifyEnabled = enabled) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (config.notifyEnabled) {
|
||||||
|
Button(onClick = { showNotifyTimePicker = true }) {
|
||||||
|
Text("%02d:%02d".format(config.notifyHour, config.notifyMinute))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(onClick = { viewModel.save {} }) {
|
||||||
|
Text(stringResource(R.string.config_save))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDatePicker) {
|
||||||
|
val initialMillis = runCatching {
|
||||||
|
java.time.LocalDate.of(
|
||||||
|
config.birthYear.takeIf { it > 0 } ?: 2000,
|
||||||
|
config.birthMonth.takeIf { it in 1..12 } ?: 1,
|
||||||
|
config.birthDay.takeIf { it in 1..31 } ?: 1,
|
||||||
|
).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
}.getOrDefault(Instant.now().toEpochMilli())
|
||||||
|
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { showDatePicker = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
datePickerState.selectedDateMillis?.let { millis ->
|
||||||
|
val date = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
viewModel.update {
|
||||||
|
it.copy(birthYear = date.year, birthMonth = date.monthValue, birthDay = date.dayOfMonth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showDatePicker = false
|
||||||
|
}) { Text(stringResource(R.string.config_save)) }
|
||||||
|
},
|
||||||
|
) { DatePicker(state = datePickerState) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBirthTimePicker) {
|
||||||
|
TimePickerDialogContent(
|
||||||
|
initialHour = config.birthHour,
|
||||||
|
initialMinute = config.birthMinute,
|
||||||
|
onDismiss = { showBirthTimePicker = false },
|
||||||
|
onConfirm = { hour, minute ->
|
||||||
|
viewModel.update { it.copy(birthHour = hour, birthMinute = minute) }
|
||||||
|
showBirthTimePicker = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showNotifyTimePicker) {
|
||||||
|
TimePickerDialogContent(
|
||||||
|
initialHour = config.notifyHour,
|
||||||
|
initialMinute = config.notifyMinute,
|
||||||
|
onDismiss = { showNotifyTimePicker = false },
|
||||||
|
onConfirm = { hour, minute ->
|
||||||
|
viewModel.update { it.copy(notifyHour = hour, notifyMinute = minute) }
|
||||||
|
showNotifyTimePicker = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun TimePickerDialogContent(
|
||||||
|
initialHour: Int,
|
||||||
|
initialMinute: Int,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (hour: Int, minute: Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val state = rememberTimePickerState(initialHour = initialHour, initialMinute = initialMinute, is24Hour = true)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onConfirm(state.hour, state.minute) }) {
|
||||||
|
Text(stringResource(R.string.config_save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = { TimePicker(state = state) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun LanguageDropdown(selected: String, onSelected: (String) -> Unit) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val languages = listOf("en", "de")
|
||||||
|
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selected,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(stringResource(R.string.config_language)) },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
languages.forEach { lang ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(lang) },
|
||||||
|
onClick = {
|
||||||
|
onSelected(lang)
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package de.ladkau.deckinadash.ui.config
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import de.ladkau.deckinadash.data.ConfigRepository
|
||||||
|
import de.ladkau.deckinadash.data.DeckConfig
|
||||||
|
import de.ladkau.deckinadash.notify.NotificationScheduler
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val configRepository = ConfigRepository(application)
|
||||||
|
|
||||||
|
// A local editable draft, seeded once from the persisted config below -
|
||||||
|
// deliberately not a live collection of configRepository.config, so
|
||||||
|
// in-progress edits aren't clobbered by DataStore's own emission of the
|
||||||
|
// write this same screen is about to make on save().
|
||||||
|
private val _config = MutableStateFlow(DeckConfig())
|
||||||
|
val config: StateFlow<DeckConfig> = _config.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_config.value = configRepository.config.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(transform: (DeckConfig) -> DeckConfig) {
|
||||||
|
_config.value = transform(_config.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persists the current draft and reschedules the daily notification -
|
||||||
|
* the other of NotificationScheduler.reschedule()'s two required call
|
||||||
|
* sites (see its own doc comment; the first is app launch). */
|
||||||
|
fun save(onSaved: () -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val toSave = _config.value.copy(configured = true)
|
||||||
|
configRepository.save(toSave)
|
||||||
|
NotificationScheduler.reschedule(getApplication(), toSave)
|
||||||
|
onSaved()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package de.ladkau.deckinadash.ui.reading
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import de.ladkau.deckinadash.R
|
||||||
|
import de.ladkau.deckinadash.data.ReadingDto
|
||||||
|
import de.ladkau.deckinadash.data.SpreadPositionDto
|
||||||
|
import de.ladkau.deckinadash.data.TransitDto
|
||||||
|
import de.ladkau.deckinadash.ui.cardDrawableRes
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReadingScreen(
|
||||||
|
onNavigateToSettings: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ReadingViewModel = viewModel(),
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
when (val state = uiState) {
|
||||||
|
is ReadingUiState.Loading -> CenteredMessage(stringResource(R.string.reading_loading))
|
||||||
|
is ReadingUiState.NotConfigured -> Column(
|
||||||
|
modifier = Modifier.wrapContentSize(Alignment.Center).padding(24.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.reading_not_configured))
|
||||||
|
Button(onClick = onNavigateToSettings, modifier = Modifier.padding(top = 16.dp)) {
|
||||||
|
Text(stringResource(R.string.nav_settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ReadingUiState.Error -> CenteredMessage(stringResource(R.string.reading_error))
|
||||||
|
is ReadingUiState.Content -> ReadingContent(state.reading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenteredMessage(text: String) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
if (text == stringResource(R.string.reading_loading)) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.padding(bottom = 16.dp))
|
||||||
|
}
|
||||||
|
Text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single scrollable page, same content order as
|
||||||
|
* watch/src/c/ui_report_window.c's build_content(): date, day
|
||||||
|
* significance, top transits+narratives, full Celtic Cross spread,
|
||||||
|
* guidance last. */
|
||||||
|
@Composable
|
||||||
|
private fun ReadingContent(reading: ReadingDto) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(reading.date, style = MaterialTheme.typography.labelLarge)
|
||||||
|
Text(
|
||||||
|
reading.daySignificance.levelLabel,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reading.transits.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.reading_transits_heading),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(reading.transits) { transit -> TransitItem(transit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.reading_spread_heading),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(reading.spread) { position -> SpreadItem(position) }
|
||||||
|
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.reading_guidance_heading),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(reading.guidance, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TransitItem(transit: TransitDto) {
|
||||||
|
Column {
|
||||||
|
Text(transit.title, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
if (transit.narrative.isNotBlank()) {
|
||||||
|
Text(transit.narrative, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SpreadItem(position: SpreadPositionDto) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Column {
|
||||||
|
Text(position.positionName, style = MaterialTheme.typography.titleSmall)
|
||||||
|
Image(
|
||||||
|
painter = painterResource(cardDrawableRes(context, position.cardSlug)),
|
||||||
|
contentDescription = position.cardName,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(160.dp)
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.graphicsLayer { rotationZ = if (position.reversed) 180f else 0f },
|
||||||
|
)
|
||||||
|
val cardTitle = if (position.reversed) {
|
||||||
|
"${position.cardName} ${stringResource(R.string.reading_reversed)}"
|
||||||
|
} else {
|
||||||
|
position.cardName
|
||||||
|
}
|
||||||
|
Text(cardTitle, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(position.positionDescription, style = MaterialTheme.typography.bodySmall)
|
||||||
|
Text(position.cardMeaning, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package de.ladkau.deckinadash.ui.reading
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import de.ladkau.deckinadash.data.ConfigRepository
|
||||||
|
import de.ladkau.deckinadash.data.ReadingDto
|
||||||
|
import de.ladkau.deckinadash.data.ReadingRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
sealed interface ReadingUiState {
|
||||||
|
data object Loading : ReadingUiState
|
||||||
|
data object NotConfigured : ReadingUiState
|
||||||
|
data class Content(val reading: ReadingDto) : ReadingUiState
|
||||||
|
data object Error : ReadingUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recomputes today's reading whenever the persisted config changes
|
||||||
|
* (including the very first emission) - the Compose analog of
|
||||||
|
* watch/src/c/reading_state.c's reading_state_recompute() own doc
|
||||||
|
* comment ("safe to call again later... overwrites the previous result
|
||||||
|
* in place"), just reactive instead of imperatively re-invoked.
|
||||||
|
*/
|
||||||
|
class ReadingViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val configRepository = ConfigRepository(application)
|
||||||
|
private val readingRepository = ReadingRepository(application)
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow<ReadingUiState>(ReadingUiState.Loading)
|
||||||
|
val uiState: StateFlow<ReadingUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
configRepository.config.collectLatest { config ->
|
||||||
|
if (!config.configured) {
|
||||||
|
_uiState.value = ReadingUiState.NotConfigured
|
||||||
|
return@collectLatest
|
||||||
|
}
|
||||||
|
_uiState.value = ReadingUiState.Loading
|
||||||
|
_uiState.value = try {
|
||||||
|
val reading = withContext(Dispatchers.Default) {
|
||||||
|
readingRepository.compute(config, Instant.now().epochSecond)
|
||||||
|
}
|
||||||
|
ReadingUiState.Content(reading)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
ReadingUiState.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package de.ladkau.deckinadash.ui.theme
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
private val LightColors = lightColorScheme()
|
||||||
|
private val DarkColors = darkColorScheme()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeckInADashTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
dynamicColor: Boolean = true,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
darkTheme -> DarkColors
|
||||||
|
else -> LightColors
|
||||||
|
}
|
||||||
|
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.22.1)
|
||||||
|
project(deckengine C)
|
||||||
|
|
||||||
|
set(CMAKE_C_STANDARD 99)
|
||||||
|
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
|
# Repo root, three levels up from android/app/src/main/jni/.
|
||||||
|
get_filename_component(REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../.." ABSOLUTE)
|
||||||
|
|
||||||
|
# Same engine + interpreter source files the watch app links (see
|
||||||
|
# watch/wscript's ENGINE_SRCS/INTERP_SRCS and its own comment on why -
|
||||||
|
# except astro.c is paired with the real, full-precision
|
||||||
|
# engine/third_party/astronomy/astronomy.c here, not the watch's
|
||||||
|
# lowprec_ephemeris.c substitute: that substitute exists solely for
|
||||||
|
# Pebble's 64KB code+data+bss budget, which Android has no equivalent
|
||||||
|
# of. json.c/reading_io.c are not linked - like the watch, this is one
|
||||||
|
# process computing and rendering its own reading, no JSON pipe between
|
||||||
|
# separate binaries.
|
||||||
|
add_library(deckengine SHARED
|
||||||
|
deck_jni.c
|
||||||
|
reading_json.c
|
||||||
|
${REPO_ROOT}/engine/src/rng.c
|
||||||
|
${REPO_ROOT}/engine/src/tarot.c
|
||||||
|
${REPO_ROOT}/engine/src/tarot_data.c
|
||||||
|
${REPO_ROOT}/engine/src/astro.c
|
||||||
|
${REPO_ROOT}/engine/src/i18n.c
|
||||||
|
${REPO_ROOT}/engine/src/reading.c
|
||||||
|
${REPO_ROOT}/engine/third_party/astronomy/astronomy.c
|
||||||
|
${REPO_ROOT}/interpreter/src/significance.c
|
||||||
|
${REPO_ROOT}/interpreter/src/narrative.c
|
||||||
|
${REPO_ROOT}/interpreter/src/guidance.c
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(deckengine m)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/* Thin JNIEXPORT shim over reading_json.c - keeps all real logic (and
|
||||||
|
* every engine/interpreter header include) in plain, JNI-free C, same
|
||||||
|
* separation watch/src/c/notify.c keeps from reading_state.c. */
|
||||||
|
#include <jni.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#include "reading_json.h"
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_de_ladkau_deckinadash_nativebridge_DeckNative_generateReadingJson(
|
||||||
|
JNIEnv *env, jobject thiz,
|
||||||
|
jint birth_year, jint birth_month, jint birth_day,
|
||||||
|
jint birth_hour, jint birth_minute, jdouble utc_offset_hours,
|
||||||
|
jdouble latitude, jdouble longitude,
|
||||||
|
jint gender, jlong utc_moment_epoch_seconds,
|
||||||
|
jstring i18n_dir, jstring lang) {
|
||||||
|
(void)thiz;
|
||||||
|
|
||||||
|
BirthData birth = {
|
||||||
|
.year = birth_year, .month = birth_month, .day = birth_day,
|
||||||
|
.hour = birth_hour, .minute = birth_minute,
|
||||||
|
.utc_offset_hours = utc_offset_hours,
|
||||||
|
.latitude = latitude, .longitude = longitude,
|
||||||
|
};
|
||||||
|
|
||||||
|
const char *i18n_dir_chars = (*env)->GetStringUTFChars(env, i18n_dir, NULL);
|
||||||
|
const char *lang_chars = (*env)->GetStringUTFChars(env, lang, NULL);
|
||||||
|
|
||||||
|
char *json = NULL;
|
||||||
|
int rc = reading_json_generate(&birth, (Gender)gender, (time_t)utc_moment_epoch_seconds,
|
||||||
|
i18n_dir_chars, lang_chars, &json);
|
||||||
|
|
||||||
|
(*env)->ReleaseStringUTFChars(env, i18n_dir, i18n_dir_chars);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, lang, lang_chars);
|
||||||
|
|
||||||
|
if (rc != 0) {
|
||||||
|
jclass exc = (*env)->FindClass(env, "java/lang/IllegalStateException");
|
||||||
|
(*env)->ThrowNew(env, exc, "reading_json_generate failed");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
jstring result = (*env)->NewStringUTF(env, json);
|
||||||
|
free(json);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
/* open_memstream() is POSIX (2008), not C99 - needs this feature-test
|
||||||
|
* macro under -std=c99 (glibc and Android's bionic both support it from
|
||||||
|
* a plain #define, no libc version gate below Android API 23, well
|
||||||
|
* under this app's minSdk 24). */
|
||||||
|
#define _POSIX_C_SOURCE 200809L
|
||||||
|
|
||||||
|
#include "reading_json.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "../../../../../engine/src/i18n.h"
|
||||||
|
#include "../../../../../engine/src/reading.h"
|
||||||
|
#include "../../../../../interpreter/src/guidance.h"
|
||||||
|
#include "../../../../../interpreter/src/narrative.h"
|
||||||
|
#include "../../../../../interpreter/src/significance.h"
|
||||||
|
|
||||||
|
/* Same convention as engine/src/main.c's own default_i18n_path() -
|
||||||
|
* duplicated rather than shared, same reasoning CLAUDE.md gives for why
|
||||||
|
* engine/src/main.c and interpreter/src/main.c each keep their own copy
|
||||||
|
* (the binaries - here, shared libraries - are never linked together). */
|
||||||
|
static void i18n_path(const char *dir, const char *lang, char *out, size_t out_size) {
|
||||||
|
snprintf(out, out_size, "%s/%s.lang", dir, lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *const k_day_level_slug[DAY_SIGNIFICANCE_COUNT] = {
|
||||||
|
"quiet", "notable", "significant", "major",
|
||||||
|
};
|
||||||
|
|
||||||
|
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 "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Transiting Saturn Square natal Sun" - the same title text
|
||||||
|
* interpreter/src/main.c's --format text prints per transit item, built
|
||||||
|
* here (rather than left to Kotlin) since it's fully localized via
|
||||||
|
* astro_body_name()/astro_aspect_name() - Kotlin never needs its own
|
||||||
|
* planet/aspect display-name table. */
|
||||||
|
static char *build_transit_title(const Aspect *a) {
|
||||||
|
char buf[160];
|
||||||
|
snprintf(buf, sizeof buf, "%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));
|
||||||
|
size_t len = strlen(buf);
|
||||||
|
char *out = malloc(len + 1);
|
||||||
|
memcpy(out, buf, len + 1);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static char *capture_narrative(const Aspect *aspect, int house) {
|
||||||
|
char stack_buf[NARRATIVE_TEXT_MAX];
|
||||||
|
narrative_write(stack_buf, sizeof stack_buf, "", aspect, house);
|
||||||
|
size_t len = strlen(stack_buf);
|
||||||
|
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
|
||||||
|
char *buf = malloc(len + 1);
|
||||||
|
memcpy(buf, stack_buf, len + 1);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
static char *capture_guidance(const DailyInterpretation *interp, const CelticCrossSpread *spread) {
|
||||||
|
char stack_buf[GUIDANCE_TEXT_MAX];
|
||||||
|
guidance_write(stack_buf, sizeof stack_buf, "", interp, spread);
|
||||||
|
size_t len = strlen(stack_buf);
|
||||||
|
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
|
||||||
|
char *buf = malloc(len + 1);
|
||||||
|
memcpy(buf, stack_buf, len + 1);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void json_string(FILE *out, const char *s) {
|
||||||
|
fputc('"', out);
|
||||||
|
for (; *s; s++) {
|
||||||
|
unsigned char c = (unsigned char)*s;
|
||||||
|
switch (c) {
|
||||||
|
case '"': fputs("\\\"", out); break;
|
||||||
|
case '\\': fputs("\\\\", out); break;
|
||||||
|
case '\n': fputs("\\n", out); break;
|
||||||
|
case '\r': fputs("\\r", out); break;
|
||||||
|
case '\t': fputs("\\t", out); break;
|
||||||
|
default:
|
||||||
|
if (c < 0x20) fprintf(out, "\\u%04x", c);
|
||||||
|
else fputc((char)c, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fputc('"', out);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void format_date(time_t utc_moment, char *out, size_t out_size) {
|
||||||
|
struct tm *utc = gmtime(&utc_moment);
|
||||||
|
snprintf(out, out_size, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
|
||||||
|
}
|
||||||
|
|
||||||
|
int reading_json_generate(const BirthData *birth, Gender gender, time_t utc_moment,
|
||||||
|
const char *i18n_dir, const char *lang, char **out_json) {
|
||||||
|
char path[512];
|
||||||
|
i18n_path(i18n_dir, lang, path, sizeof path);
|
||||||
|
i18n_load(path); /* non-fatal on failure - falls back to built-in English, same as engine/src/main.c */
|
||||||
|
|
||||||
|
/* The tarot seed is today's UTC date, same convention
|
||||||
|
* dist/run-engine.sh and watch/src/c/reading_state.c use. */
|
||||||
|
char seed[16];
|
||||||
|
format_date(utc_moment, seed, sizeof seed);
|
||||||
|
|
||||||
|
DailyReading reading;
|
||||||
|
reading_generate(seed, birth, gender, utc_moment, &reading);
|
||||||
|
|
||||||
|
DailyInterpretation interp;
|
||||||
|
interpret_daily_reading(&reading, &interp);
|
||||||
|
|
||||||
|
size_t json_len = 0;
|
||||||
|
char *json_buf = NULL;
|
||||||
|
FILE *out = open_memstream(&json_buf, &json_len);
|
||||||
|
if (!out) return 1;
|
||||||
|
|
||||||
|
fprintf(out, "{\n");
|
||||||
|
|
||||||
|
char date_buf[32];
|
||||||
|
format_date(reading.utc_moment, date_buf, sizeof date_buf);
|
||||||
|
fprintf(out, " \"date\": ");
|
||||||
|
json_string(out, date_buf);
|
||||||
|
fprintf(out, ",\n \"gender\": ");
|
||||||
|
json_string(out, tarot_gender_slug(reading.gender));
|
||||||
|
fprintf(out, ",\n");
|
||||||
|
|
||||||
|
fprintf(out, " \"day_significance\": {\n");
|
||||||
|
fprintf(out, " \"level_slug\": \"%s\",\n", k_day_level_slug[interp.day_level]);
|
||||||
|
fprintf(out, " \"level_label\": ");
|
||||||
|
json_string(out, day_level_name(interp.day_level));
|
||||||
|
fprintf(out, ",\n \"rank\": %d,\n \"count\": %d,\n \"deserves_framing\": %s\n",
|
||||||
|
interp.day_level + 1, DAY_SIGNIFICANCE_COUNT,
|
||||||
|
interpretation_deserves_framing(&interp) ? "true" : "false");
|
||||||
|
fprintf(out, " },\n");
|
||||||
|
|
||||||
|
fprintf(out, " \"transits\": [\n");
|
||||||
|
for (int i = 0; i < interp.top_item_count; i++) {
|
||||||
|
const SignificantItem *item = &interp.top_items[i];
|
||||||
|
const Aspect *a = &item->aspect;
|
||||||
|
const PlanetPosition *tp = &reading.transits.bodies[a->transiting_planet];
|
||||||
|
char *title = build_transit_title(a);
|
||||||
|
char *narrative = capture_narrative(a, tp->house);
|
||||||
|
|
||||||
|
fprintf(out, " {\n");
|
||||||
|
fprintf(out, " \"title\": ");
|
||||||
|
json_string(out, title);
|
||||||
|
fprintf(out, ",\n \"transiting_planet_slug\": \"%s\",\n", astro_body_slug(a->transiting_planet));
|
||||||
|
fprintf(out, " \"natal_planet_slug\": \"%s\",\n", astro_body_slug(a->natal_planet));
|
||||||
|
fprintf(out, " \"aspect_slug\": \"%s\",\n", astro_aspect_slug(a->type));
|
||||||
|
fprintf(out, " \"orb\": %.4f,\n", a->orb);
|
||||||
|
fprintf(out, " \"score\": %.4f,\n", item->score);
|
||||||
|
fprintf(out, " \"narrative\": ");
|
||||||
|
json_string(out, narrative);
|
||||||
|
fprintf(out, "\n }%s\n", i + 1 < interp.top_item_count ? "," : "");
|
||||||
|
|
||||||
|
free(title);
|
||||||
|
free(narrative);
|
||||||
|
}
|
||||||
|
fprintf(out, " ],\n");
|
||||||
|
|
||||||
|
char *guidance = capture_guidance(&interp, &reading.spread);
|
||||||
|
fprintf(out, " \"guidance\": ");
|
||||||
|
json_string(out, guidance);
|
||||||
|
fprintf(out, ",\n");
|
||||||
|
free(guidance);
|
||||||
|
|
||||||
|
fprintf(out, " \"spread\": [\n");
|
||||||
|
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||||
|
CelticCrossPosition position = (CelticCrossPosition)i;
|
||||||
|
const TarotDraw *draw = &reading.spread.positions[i];
|
||||||
|
|
||||||
|
fprintf(out, " {\n");
|
||||||
|
fprintf(out, " \"position_slug\": \"%s\",\n", tarot_position_slug(position));
|
||||||
|
fprintf(out, " \"position_name\": ");
|
||||||
|
json_string(out, tarot_position_name(position, reading.gender));
|
||||||
|
fprintf(out, ",\n \"position_description\": ");
|
||||||
|
json_string(out, tarot_position_description(position, reading.gender));
|
||||||
|
fprintf(out, ",\n \"card_slug\": \"%s\",\n", tarot_card_slug(draw->card));
|
||||||
|
fprintf(out, " \"card_name\": ");
|
||||||
|
json_string(out, tarot_card_name(draw->card));
|
||||||
|
fprintf(out, ",\n \"reversed\": %s,\n", draw->reversed ? "true" : "false");
|
||||||
|
fprintf(out, " \"card_meaning\": ");
|
||||||
|
json_string(out, tarot_card_meaning(draw->card, draw->reversed));
|
||||||
|
fprintf(out, "\n }%s\n", i + 1 < TAROT_SPREAD_SIZE ? "," : "");
|
||||||
|
}
|
||||||
|
fprintf(out, " ]\n");
|
||||||
|
|
||||||
|
fprintf(out, "}\n");
|
||||||
|
fclose(out);
|
||||||
|
|
||||||
|
*out_json = json_buf;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#ifndef DECKINADASH_READING_JSON_H
|
||||||
|
#define DECKINADASH_READING_JSON_H
|
||||||
|
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
#include "../../../../../engine/src/astro.h"
|
||||||
|
#include "../../../../../engine/src/tarot.h"
|
||||||
|
|
||||||
|
/* Recomputes a full day's reading (astro + tarot + interpretation) from
|
||||||
|
* `birth`/`gender`/`utc_moment` - same inputs and same
|
||||||
|
* reading_generate()/interpret_daily_reading() calls
|
||||||
|
* watch/src/c/reading_state.c makes - and serializes everything a UI
|
||||||
|
* needs to render it (in watch/src/c/ui_report_window.c's own section
|
||||||
|
* order: date, day significance, top transits+narratives, full Celtic
|
||||||
|
* Cross spread, guidance last) as one JSON string, structurally a port
|
||||||
|
* of interpreter/src/main.c's interp_print_json() targeting a heap
|
||||||
|
* buffer instead of stdout.
|
||||||
|
*
|
||||||
|
* `i18n_dir`/`lang` locate `<i18n_dir>/<lang>.lang` (same convention as
|
||||||
|
* engine/src/main.c's default_i18n_path()); a missing/unreadable file is
|
||||||
|
* a non-fatal fallback to built-in English (i18n_load()'s own contract),
|
||||||
|
* not an error returned here.
|
||||||
|
*
|
||||||
|
* On success, returns a malloc'd, null-terminated JSON string in
|
||||||
|
* `*out_json` (caller must free()) and returns 0. Returns non-zero and
|
||||||
|
* leaves `*out_json` untouched only if the underlying open_memstream()
|
||||||
|
* allocation itself fails - not a condition this codebase's other
|
||||||
|
* callers (deck-engine, interpreter-cli) treat as reachable in practice. */
|
||||||
|
int reading_json_generate(const BirthData *birth, Gender gender, time_t utc_moment,
|
||||||
|
const char *i18n_dir, const char *lang, char **out_json);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Status-bar notification icon: must be a flat white silhouette on
|
||||||
|
transparent (the system applies its own tint) - a simple crescent
|
||||||
|
moon + star, evoking the astrology half of this app's reading. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFF">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12.5,3 C8.4,3.6 5.3,7.1 5.3,11.4 C5.3,16.1 9.1,19.9 13.8,19.9 C16.9,19.9 19.6,18.2 21,15.7 C19.9,16.4 18.6,16.8 17.2,16.8 C12.9,16.8 9.4,13.3 9.4,9 C9.4,6.7 10.5,4.6 12.5,3 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M18.5,3 L19.2,4.8 L21,5.5 L19.2,6.2 L18.5,8 L17.8,6.2 L16,5.5 L17.8,4.8 Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#1B1B2F</color>
|
||||||
|
<color name="purple_primary">#6750A4</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Deck in a Dash</string>
|
||||||
|
|
||||||
|
<string name="nav_reading">Today\'s Reading</string>
|
||||||
|
<string name="nav_settings">Settings</string>
|
||||||
|
<string name="nav_about">About</string>
|
||||||
|
|
||||||
|
<string name="notification_channel_name">Daily reading</string>
|
||||||
|
<string name="notification_channel_description">A daily notification with your tarot and astrology reading</string>
|
||||||
|
<string name="notification_title">Today\'s reading: %1$s</string>
|
||||||
|
|
||||||
|
<string name="config_title">Settings</string>
|
||||||
|
<string name="config_birth_date">Birth date</string>
|
||||||
|
<string name="config_birth_time">Birth time</string>
|
||||||
|
<string name="config_utc_offset">UTC offset (hours)</string>
|
||||||
|
<string name="config_latitude">Latitude</string>
|
||||||
|
<string name="config_longitude">Longitude</string>
|
||||||
|
<string name="config_gender">Reading for</string>
|
||||||
|
<string name="config_gender_unspecified">Unspecified</string>
|
||||||
|
<string name="config_gender_male">Male</string>
|
||||||
|
<string name="config_gender_female">Female</string>
|
||||||
|
<string name="config_language">Language</string>
|
||||||
|
<string name="config_notify_enabled">Daily notification</string>
|
||||||
|
<string name="config_notify_time">Notification time</string>
|
||||||
|
<string name="config_save">Save</string>
|
||||||
|
|
||||||
|
<string name="reading_not_configured">Set your birth details in Settings to see today\'s reading.</string>
|
||||||
|
<string name="reading_loading">Computing today\'s reading…</string>
|
||||||
|
<string name="reading_error">Could not compute today\'s reading.</string>
|
||||||
|
<string name="reading_guidance_heading">Guidance</string>
|
||||||
|
<string name="reading_transits_heading">Significant transits</string>
|
||||||
|
<string name="reading_spread_heading">Celtic Cross</string>
|
||||||
|
<string name="reading_reversed">(Reversed)</string>
|
||||||
|
|
||||||
|
<string name="about_body">Deck in a Dash shows a daily personalized tarot (Celtic Cross) and astrology (natal chart + transits) reading.\n\nCard and spread text is condensed from A. E. Waite\'s The Pictorial Key to the Tarot (1911, public domain). Transit narrative text is condensed from Sepharial\'s Transits and Planetary Periods (1920, public domain).</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.DeckInADash" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "9.2.1"
|
||||||
|
kotlin = "2.2.20"
|
||||||
|
coreKtx = "1.17.0"
|
||||||
|
lifecycleRuntimeKtx = "2.9.4"
|
||||||
|
activityCompose = "1.13.0"
|
||||||
|
composeBom = "2026.06.01"
|
||||||
|
navigationCompose = "2.9.8"
|
||||||
|
datastorePreferences = "1.2.1"
|
||||||
|
workRuntimeKtx = "2.11.2"
|
||||||
|
kotlinxSerializationJson = "1.11.0"
|
||||||
|
kotlinxCoroutines = "1.11.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
|
||||||
|
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" }
|
||||||
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,9 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
+248
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# gradlew start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh gradlew
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+82
@@ -0,0 +1,82 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem gradlew startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute gradlew
|
||||||
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
|
:exitWithErrorLevel
|
||||||
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Downsizes ../../res/img/*.jpeg into app/src/main/res/drawable-nodpi/*.webp
|
||||||
|
for the Android app.
|
||||||
|
|
||||||
|
Structurally a sibling of ../../watch/scripts/gen_card_images.py (same
|
||||||
|
CARDS slug/filename table - matching engine/src/tarot_data.c's own
|
||||||
|
k_card_slug, so app/src/main/res/drawable-nodpi/<slug>.webp resolves via
|
||||||
|
plain resource-name lookup, see app/src/main/java/.../ui/CardArt.kt -
|
||||||
|
and the same per-file mtime skip-if-up-to-date logic), but sized and
|
||||||
|
encoded for a phone screen instead of Pebble's hardware-forced 72px PNG:
|
||||||
|
400px wide, lossy WebP (quality 85) - these are painted illustrations,
|
||||||
|
not line art, so WebP meaningfully beats PNG here, and 21MB of source
|
||||||
|
JPEGs would otherwise bloat the APK.
|
||||||
|
|
||||||
|
Generated output, not committed (see the repo's root .gitignore's
|
||||||
|
/android/app/src/main/res/drawable-nodpi entry) - regenerate by running
|
||||||
|
this script directly or via Gradle's generateCardArt task (wired into
|
||||||
|
app's preBuild, see ../app/build.gradle.kts). Requires Pillow (`pip
|
||||||
|
install Pillow`), already part of build-image's toolchain.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
SRC_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "res", "img"))
|
||||||
|
OUT_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "app", "src", "main", "res", "drawable-nodpi"))
|
||||||
|
|
||||||
|
CARD_WIDTH = 400
|
||||||
|
WEBP_QUALITY = 85
|
||||||
|
|
||||||
|
# (source file in res/img/, slug matching engine/src/tarot_data.c's
|
||||||
|
# k_card_slug table) - in TarotCard enum order (CARD_FOOL = 0 ... CARD_WORLD).
|
||||||
|
CARDS = [
|
||||||
|
("RWS_Tarot_00_Fool.jpeg", "fool"),
|
||||||
|
("RWS_Tarot_01_Magician.jpeg", "magician"),
|
||||||
|
("RWS_Tarot_02_High_Priestess.jpeg", "high_priestess"),
|
||||||
|
("RWS_Tarot_03_Empress.jpeg", "empress"),
|
||||||
|
("RWS_Tarot_04_Emperor.jpeg", "emperor"),
|
||||||
|
("RWS_Tarot_05_Hierophant.jpeg", "hierophant"),
|
||||||
|
("RWS_Tarot_06_Lovers.jpeg", "lovers"),
|
||||||
|
("RWS_Tarot_07_Chariot.jpeg", "chariot"),
|
||||||
|
("RWS_Tarot_08_Strength.jpeg", "strength"),
|
||||||
|
("RWS_Tarot_09_Hermit.jpeg", "hermit"),
|
||||||
|
("RWS_Tarot_10_Wheel_of_Fortune.jpeg", "wheel_of_fortune"),
|
||||||
|
("RWS_Tarot_11_Justice.jpeg", "justice"),
|
||||||
|
("RWS_Tarot_12_Hanged_Man.jpeg", "hanged_man"),
|
||||||
|
("RWS_Tarot_13_Death.jpeg", "death"),
|
||||||
|
("RWS_Tarot_14_Temperance.jpeg", "temperance"),
|
||||||
|
("RWS_Tarot_15_Devil.jpeg", "devil"),
|
||||||
|
("RWS_Tarot_16_Tower.jpeg", "tower"),
|
||||||
|
("RWS_Tarot_17_Star.jpeg", "star"),
|
||||||
|
("RWS_Tarot_18_Moon.jpeg", "moon"),
|
||||||
|
("RWS_Tarot_19_Sun.jpeg", "sun"),
|
||||||
|
("RWS_Tarot_20_Judgement.jpeg", "judgement"),
|
||||||
|
("RWS_Tarot_21_World.jpeg", "world"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
written = 0
|
||||||
|
for src_name, slug in CARDS:
|
||||||
|
src_path = os.path.join(SRC_DIR, src_name)
|
||||||
|
out_path = os.path.join(OUT_DIR, f"{slug}.webp")
|
||||||
|
|
||||||
|
if os.path.exists(out_path) and os.path.getmtime(out_path) >= os.path.getmtime(src_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
with Image.open(src_path) as img:
|
||||||
|
ratio = CARD_WIDTH / img.width
|
||||||
|
height = round(img.height * ratio)
|
||||||
|
resized = img.convert("RGB").resize((CARD_WIDTH, height), Image.LANCZOS)
|
||||||
|
resized.save(out_path, "WEBP", quality=WEBP_QUALITY)
|
||||||
|
written += 1
|
||||||
|
|
||||||
|
print(f"wrote {written}/{len(CARDS)} card images to {OUT_DIR}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generates the Android launcher icon from ../../res/logo.png.
|
||||||
|
|
||||||
|
Produces:
|
||||||
|
- mipmap-<density>/ic_launcher_foreground.png - the logo centered,
|
||||||
|
scaled to fit the adaptive-icon safe zone (66% of a 108dp canvas),
|
||||||
|
transparent background - paired at runtime with
|
||||||
|
@color/ic_launcher_background (see mipmap-anydpi-v26/ic_launcher.xml,
|
||||||
|
committed - a static reference, not generated) for API 26+.
|
||||||
|
- mipmap-<density>/ic_launcher.png / ic_launcher_round.png - the same
|
||||||
|
logo flattened onto ic_launcher_background, for API <26 devices that
|
||||||
|
can't use the adaptive-icon XML at all.
|
||||||
|
|
||||||
|
Generated output, not committed (see the repo's root .gitignore's
|
||||||
|
/android/app/src/main/res/mipmap-* entries) - regenerate by running this
|
||||||
|
script directly or via Gradle's generateLauncherIcon task (wired into
|
||||||
|
app's preBuild, see ../app/build.gradle.kts). Requires Pillow, already
|
||||||
|
part of build-image's toolchain.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
LOGO_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "res", "logo.png"))
|
||||||
|
RES_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "app", "src", "main", "res"))
|
||||||
|
|
||||||
|
BACKGROUND_RGBA = (27, 27, 47, 255) # matches @color/ic_launcher_background
|
||||||
|
|
||||||
|
# (density qualifier, adaptive-icon canvas px, legacy launcher px)
|
||||||
|
DENSITIES = [
|
||||||
|
("mdpi", 108, 48),
|
||||||
|
("hdpi", 162, 72),
|
||||||
|
("xhdpi", 216, 96),
|
||||||
|
("xxhdpi", 324, 144),
|
||||||
|
("xxxhdpi", 432, 192),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Adaptive icons render only the inner ~66% "safe zone" reliably across
|
||||||
|
# launchers that apply their own mask shape.
|
||||||
|
SAFE_ZONE_RATIO = 0.66
|
||||||
|
|
||||||
|
|
||||||
|
def resized_logo(logo, target_px):
|
||||||
|
ratio = target_px / max(logo.width, logo.height)
|
||||||
|
size = (round(logo.width * ratio), round(logo.height * ratio))
|
||||||
|
return logo.resize(size, Image.LANCZOS)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logo = Image.open(LOGO_PATH).convert("RGBA")
|
||||||
|
|
||||||
|
for density, canvas_px, legacy_px in DENSITIES:
|
||||||
|
mipmap_dir = os.path.join(RES_DIR, f"mipmap-{density}")
|
||||||
|
os.makedirs(mipmap_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Adaptive-icon foreground: transparent canvas, logo scaled to the
|
||||||
|
# safe zone and centered.
|
||||||
|
foreground = Image.new("RGBA", (canvas_px, canvas_px), (0, 0, 0, 0))
|
||||||
|
fg_logo = resized_logo(logo, round(canvas_px * SAFE_ZONE_RATIO))
|
||||||
|
offset = ((canvas_px - fg_logo.width) // 2, (canvas_px - fg_logo.height) // 2)
|
||||||
|
foreground.paste(fg_logo, offset, fg_logo)
|
||||||
|
foreground.save(os.path.join(mipmap_dir, "ic_launcher_foreground.png"))
|
||||||
|
|
||||||
|
# Legacy (<API26) flattened fallback, same for round and square -
|
||||||
|
# launchers on those versions apply their own circular crop.
|
||||||
|
legacy = Image.new("RGBA", (legacy_px, legacy_px), BACKGROUND_RGBA)
|
||||||
|
legacy_logo = resized_logo(logo, round(legacy_px * SAFE_ZONE_RATIO))
|
||||||
|
legacy_offset = ((legacy_px - legacy_logo.width) // 2, (legacy_px - legacy_logo.height) // 2)
|
||||||
|
legacy.paste(legacy_logo, legacy_offset, legacy_logo)
|
||||||
|
legacy.convert("RGB").save(os.path.join(mipmap_dir, "ic_launcher.png"))
|
||||||
|
legacy.convert("RGB").save(os.path.join(mipmap_dir, "ic_launcher_round.png"))
|
||||||
|
|
||||||
|
print(f"wrote launcher icons for {len(DENSITIES)} densities to {RES_DIR}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "deck-in-a-dash-android"
|
||||||
|
include(":app")
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds the release build environment image (build-image/Dockerfile)
|
||||||
|
# locally, tagged with build-image/VERSION and :latest. Does not push -
|
||||||
|
# test it with ./run-image.sh first, then publish with ./upload-image.sh.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
ROOT="$(pwd)"
|
||||||
|
|
||||||
|
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 \
|
||||||
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
|
[ -f "$ROOT/registry.env" ] \
|
||||||
|
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$ROOT/registry.env"
|
||||||
|
|
||||||
|
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
|
||||||
|
|
||||||
|
VERSION="$(<"$ROOT/build-image/VERSION")"
|
||||||
|
[ -n "$VERSION" ] || fail "build-image/VERSION is empty"
|
||||||
|
|
||||||
|
echo "== Building $REGISTRY_IMAGE:$VERSION =="
|
||||||
|
docker build \
|
||||||
|
-t "$REGISTRY_IMAGE:$VERSION" \
|
||||||
|
-t "$REGISTRY_IMAGE:latest" \
|
||||||
|
"$ROOT/build-image"
|
||||||
|
|
||||||
|
echo "== Done =="
|
||||||
|
echo "Image: $REGISTRY_IMAGE:$VERSION (and :latest)"
|
||||||
|
echo "Test it with ./run-image.sh, then publish with ./upload-image.sh"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Build environment for deck_in_a_dash release artifacts: a plain C
|
||||||
|
# toolchain for the desktop deck-engine/interpreter-cli binaries (`make
|
||||||
|
# test`, `make package`), the Pebble SDK for the watch app (`make
|
||||||
|
# watchapp`), and the Android SDK/NDK for the Android app (`make
|
||||||
|
# android`), pinned to the versions validated on the maintainer's dev
|
||||||
|
# machine. Rebuild with ../build-image.sh whenever a version below
|
||||||
|
# changes.
|
||||||
|
#
|
||||||
|
# Does NOT contain any secrets - registry/SFTP credentials are injected
|
||||||
|
# at job runtime from Gitea Actions secrets, never baked into this image.
|
||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
ARG PEBBLE_TOOL_VERSION=5.0.39
|
||||||
|
ARG PEBBLE_SDK_CORE_VERSION=4.17
|
||||||
|
ARG NODE_VERSION=24.16.0
|
||||||
|
ARG ANDROID_CMDLINE_TOOLS_VERSION=9862592
|
||||||
|
ARG ANDROID_PLATFORM_VERSION=36
|
||||||
|
ARG ANDROID_BUILD_TOOLS_VERSION=36.0.0
|
||||||
|
ARG ANDROID_NDK_VERSION=30.0.14904198
|
||||||
|
ARG ANDROID_CMAKE_VERSION=3.22.1
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
PATH=/root/.local/bin:${PATH}
|
||||||
|
|
||||||
|
# build-essential: gcc/make for the desktop deck-engine/interpreter-cli
|
||||||
|
# binaries (`make test`, `make package`) and for the Android app's JNI
|
||||||
|
# native library (app/src/main/jni/CMakeLists.txt - the NDK's own
|
||||||
|
# toolchain does the actual cross-compiling, but AGP's build needs a
|
||||||
|
# host C toolchain available too).
|
||||||
|
# python3/python3-venv/python3-pip: `pebble sdk install` below creates a
|
||||||
|
# venv per SDK version; Pillow (installed below) is needed at `pebble
|
||||||
|
# build` time by watch/scripts/gen_card_images.py and gen_app_icon.py,
|
||||||
|
# and by the Android app's android/scripts/gen_card_images.py and
|
||||||
|
# gen_launcher_icon.py.
|
||||||
|
# git/curl/ca-certificates/unzip/tar/xz-utils: checkout, tool downloads,
|
||||||
|
# and `make package`/`make watchapp`/`make android`'s `git describe
|
||||||
|
# --tags` versioning.
|
||||||
|
# openssh-client: the build workflow's `sftp` publish to dl.ladkau.de.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential python3 python3-venv python3-pip \
|
||||||
|
git curl ca-certificates unzip tar xz-utils openssh-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN pip3 install --no-cache-dir Pillow
|
||||||
|
|
||||||
|
# Node.js: `pebble sdk install` below runs `npm install` for the SDK
|
||||||
|
# core's bundled webpack tooling - it does not bring its own node/npm.
|
||||||
|
RUN curl -sSL -o /tmp/node.tar.xz \
|
||||||
|
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
|
||||||
|
&& tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 \
|
||||||
|
&& rm /tmp/node.tar.xz
|
||||||
|
|
||||||
|
# Pebble SDK: pebble-tool (via uv, pipx-style isolated install) + the
|
||||||
|
# sdk-core toolchain/webpack deps for PEBBLE_SDK_CORE_VERSION, both
|
||||||
|
# fully resolved here so no `docker run` of this image needs network
|
||||||
|
# access to install anything before `pebble build` works.
|
||||||
|
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||||
|
&& uv tool install "pebble-tool==${PEBBLE_TOOL_VERSION}" \
|
||||||
|
&& pebble sdk install "${PEBBLE_SDK_CORE_VERSION}"
|
||||||
|
|
||||||
|
# Android SDK/NDK: openjdk-17 (Gradle/AGP's own minimum JDK) + the
|
||||||
|
# cmdline-tools' own sdkmanager to install exactly the platform/build-
|
||||||
|
# tools/NDK/CMake versions android/app/build.gradle.kts pins - unlike
|
||||||
|
# the Pebble/CLI build path above, `make android`'s own Gradle build
|
||||||
|
# still needs network access at build time (Gradle/AGP/androidx
|
||||||
|
# dependency resolution against Google's Maven + Maven Central - see the
|
||||||
|
# root CLAUDE.md's Android section for why this is an accepted,
|
||||||
|
# deliberate deviation from this image's otherwise-offline build
|
||||||
|
# philosophy). android/gradlew resolves its own pinned Gradle version -
|
||||||
|
# no Gradle binary is baked into this image.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openjdk-17-jdk-headless \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV ANDROID_HOME=/opt/android-sdk \
|
||||||
|
ANDROID_SDK_ROOT=/opt/android-sdk
|
||||||
|
ENV PATH=${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}
|
||||||
|
|
||||||
|
RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \
|
||||||
|
&& curl -sSL -o /tmp/cmdline-tools.zip \
|
||||||
|
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" \
|
||||||
|
&& unzip -q /tmp/cmdline-tools.zip -d "${ANDROID_HOME}/cmdline-tools" \
|
||||||
|
&& mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
|
||||||
|
&& rm /tmp/cmdline-tools.zip \
|
||||||
|
&& yes | sdkmanager --licenses >/dev/null \
|
||||||
|
&& sdkmanager --install \
|
||||||
|
"platform-tools" \
|
||||||
|
"platforms;android-${ANDROID_PLATFORM_VERSION}" \
|
||||||
|
"build-tools;${ANDROID_BUILD_TOOLS_VERSION}" \
|
||||||
|
"ndk;${ANDROID_NDK_VERSION}" \
|
||||||
|
"cmake;${ANDROID_CMAKE_VERSION}"
|
||||||
|
|
||||||
|
ENV ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${ANDROID_NDK_VERSION}
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
2
|
||||||
+52
-15
@@ -17,6 +17,7 @@ same `--lang`) for its own report, independently of `deck-engine`.
|
|||||||
deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
|
deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
|
||||||
--birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>
|
--birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>
|
||||||
[--date YYYY-MM-DDTHH:MM] [--format text|html|json]
|
[--date YYYY-MM-DDTHH:MM] [--format text|html|json]
|
||||||
|
[--gender male|female|unspecified]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Required | Format | Meaning |
|
| Flag | Required | Format | Meaning |
|
||||||
@@ -29,6 +30,7 @@ deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
|
|||||||
| `--birth-lon` | yes | decimal degrees | Birth longitude, east positive. |
|
| `--birth-lon` | yes | decimal degrees | Birth longitude, east positive. |
|
||||||
| `--date` | no | `YYYY-MM-DDTHH:MM[:SS]` | UTC moment used for today's transits and as the moment the tarot seed is drawn against. Defaults to the current system time. |
|
| `--date` | no | `YYYY-MM-DDTHH:MM[:SS]` | UTC moment used for today's transits and as the moment the tarot seed is drawn against. Defaults to the current system time. |
|
||||||
| `--format` | no | `text` \| `html` \| `json` | Output format, defaults to `text`. `json` always uses language-independent identifiers, ignoring `--lang`. |
|
| `--format` | no | `text` \| `html` \| `json` | Output format, defaults to `text`. `json` always uses language-independent identifiers, ignoring `--lang`. |
|
||||||
|
| `--gender` | no | `male` \| `female` \| `unspecified` | Who the reading is for. Only affects the Celtic Cross position text's pronouns (e.g. "His House" vs. "Her House" vs. "Their House") - never the chart/draw itself. Defaults to `unspecified` (pronoun-neutral phrasing). |
|
||||||
| `--lang` | no | language code, e.g. `en`, `de` | Reading language, defaults to `en`. Matches a file named `<code>.lang` in `--i18n-dir` (see "Translations" below). Unknown codes or missing files fall back to English with a warning on stderr. |
|
| `--lang` | no | language code, e.g. `en`, `de` | Reading language, defaults to `en`. Matches a file named `<code>.lang` in `--i18n-dir` (see "Translations" below). Unknown codes or missing files fall back to English with a warning on stderr. |
|
||||||
| `--i18n-dir` | no | path | Directory to look for `<lang>.lang` in. Defaults to an `i18n/` directory next to the binary itself (resolved from `argv[0]`, so `dist/deck-engine` finds `dist/i18n/` regardless of the caller's working directory). |
|
| `--i18n-dir` | no | path | Directory to look for `<lang>.lang` in. Defaults to an `i18n/` directory next to the binary itself (resolved from `argv[0]`, so `dist/deck-engine` finds `dist/i18n/` regardless of the caller's working directory). |
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ use:
|
|||||||
birth_utc_offset=2
|
birth_utc_offset=2
|
||||||
birth_lat=52.5200
|
birth_lat=52.5200
|
||||||
birth_lon=13.4050
|
birth_lon=13.4050
|
||||||
|
gender=unspecified
|
||||||
lang=en
|
lang=en
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -59,8 +62,8 @@ use:
|
|||||||
with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.). `run-engine.sh`
|
with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.). `run-engine.sh`
|
||||||
checks every birth field for an empty value or a leftover `YOUR_`
|
checks every birth field for an empty value or a leftover `YOUR_`
|
||||||
placeholder and refuses to run with a clear error until the file is
|
placeholder and refuses to run with a clear error until the file is
|
||||||
filled in; `lang` has no such check since it always has a usable
|
filled in; `gender` and `lang` have no such check since they always
|
||||||
default (`en`). Since `dist/` itself can be wiped (`make clean`, or
|
have a usable default (`unspecified`/`en`). Since `dist/` itself can be wiped (`make clean`, or
|
||||||
deleting the directory), the build also keeps a durable backup at
|
deleting the directory), the build also keeps a durable backup at
|
||||||
`engine/scripts/user.properties.local` (gitignored) once the file
|
`engine/scripts/user.properties.local` (gitignored) once the file
|
||||||
looks filled in, and restores from it if `dist/user.properties` is
|
looks filled in, and restores from it if `dist/user.properties` is
|
||||||
@@ -81,6 +84,14 @@ catalog with the built-in English text as the fallback for any key the
|
|||||||
catalog doesn't have. See `CLAUDE.md`'s "Translations" section for the
|
catalog doesn't have. See `CLAUDE.md`'s "Translations" section for the
|
||||||
key-naming convention and how to add a new language.
|
key-naming convention and how to add a new language.
|
||||||
|
|
||||||
|
Celtic Cross position text additionally varies with `--gender`: the
|
||||||
|
plain key (e.g. `position.present.desc`) is the pronoun-neutral wording
|
||||||
|
used for `unspecified`, and `.male`/`.female`-suffixed keys (e.g.
|
||||||
|
`position.present.desc.male`) hold the gendered wording where it
|
||||||
|
differs. A position whose wording doesn't vary by gender (e.g. "The
|
||||||
|
Outcome") simply has no `.male`/`.female` keys - the neutral key's
|
||||||
|
translation is used for every `--gender` value in that case.
|
||||||
|
|
||||||
`engine/i18n/en.lang` and `de.lang` are the shipped translation files;
|
`engine/i18n/en.lang` and `de.lang` are the shipped translation files;
|
||||||
`make` copies `engine/i18n/*.lang` to `dist/i18n/` (refreshed on every
|
`make` copies `engine/i18n/*.lang` to `dist/i18n/` (refreshed on every
|
||||||
build, like `dist/img/`). Adding a language is just dropping another
|
build, like `dist/img/`). Adding a language is just dropping another
|
||||||
@@ -146,7 +157,7 @@ Transiting Sun Opposition natal Moon (orb 3.7°)
|
|||||||
|
|
||||||
===== Celtic Cross =====
|
===== Celtic Cross =====
|
||||||
The Present The High Priestess (Reversed)
|
The Present The High Priestess (Reversed)
|
||||||
This covers him: the general influence affecting the matter.
|
This covers them: the general influence affecting the matter.
|
||||||
Passion, moral or physical ardour, conceit, surface knowledge.
|
Passion, moral or physical ardour, conceit, surface knowledge.
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
@@ -171,8 +182,8 @@ cards rendered as an image:
|
|||||||
|
|
||||||
### `--format json`
|
### `--format json`
|
||||||
|
|
||||||
A single JSON object mirroring `DailyReading` exactly — `natal`,
|
A single JSON object mirroring `DailyReading` exactly — `date`, `gender`,
|
||||||
`transits`, `spread` — using the **slug** accessors
|
`natal`, `transits`, `spread` — using the **slug** accessors
|
||||||
(`astro_body_slug()`, `astro_sign_slug()`, `astro_moon_phase_slug()`,
|
(`astro_body_slug()`, `astro_sign_slug()`, `astro_moon_phase_slug()`,
|
||||||
`astro_aspect_slug()`, `tarot_card_slug()`, `tarot_position_slug()`)
|
`astro_aspect_slug()`, `tarot_card_slug()`, `tarot_position_slug()`)
|
||||||
rather than the display-name ones, so the output is identical regardless
|
rather than the display-name ones, so the output is identical regardless
|
||||||
@@ -182,6 +193,8 @@ read.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"date": "2026-07-16",
|
||||||
|
"gender": "unspecified",
|
||||||
"natal": {
|
"natal": {
|
||||||
"bodies": [
|
"bodies": [
|
||||||
{"body": "sun", "sign": "taurus", "degree_in_sign": 23.4926, "ecliptic_longitude": 53.4926, "house": 3},
|
{"body": "sun", "sign": "taurus", "degree_in_sign": 23.4926, "ecliptic_longitude": 53.4926, "house": 3},
|
||||||
@@ -210,6 +223,17 @@ read.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `date` is the UTC calendar date of `--date` (or the current system time
|
||||||
|
if `--date` was omitted) — the day this reading is *for*, not
|
||||||
|
necessarily the day it was generated on. `reading_generate()` stores
|
||||||
|
the full moment in `DailyReading.utc_moment`; only the date part is
|
||||||
|
serialized here since that's the only thing the rest of this format
|
||||||
|
(and the interpreter's own reports, which read this field back — see
|
||||||
|
"The interpreter" below) ever display.
|
||||||
|
- `gender` is `--gender`'s value (`male`/`female`/`unspecified`,
|
||||||
|
`tarot_gender_slug()`) — who the reading is for. Only affects the
|
||||||
|
Celtic Cross position text's pronouns, nothing else in this document.
|
||||||
|
The interpreter reads this field back the same way it reads `date`.
|
||||||
- `bodies` is always `NUM_BODIES` (10) entries, Sun..Pluto in `Body` enum
|
- `bodies` is always `NUM_BODIES` (10) entries, Sun..Pluto in `Body` enum
|
||||||
order; `spread.positions` is always `TAROT_SPREAD_SIZE` (10) entries in
|
order; `spread.positions` is always `TAROT_SPREAD_SIZE` (10) entries in
|
||||||
`CelticCrossPosition` enum order (Present, Challenge, Crown,
|
`CelticCrossPosition` enum order (Present, Challenge, Crown,
|
||||||
@@ -279,11 +303,16 @@ dist/run-interpreter.sh html de # override format/language for this run
|
|||||||
### `--format text`
|
### `--format text`
|
||||||
|
|
||||||
A plain-text report, printed in a fixed order —
|
A plain-text report, printed in a fixed order —
|
||||||
1. the day's overall significance level (`Quiet`/`Notable`/
|
1. the day this reading is for (`Reading for: 2026-07-16`), from the
|
||||||
|
input JSON's top-level `date` field (see `--format json` above) —
|
||||||
|
not necessarily the day the report is actually being read on, if
|
||||||
|
`reading.json` was generated earlier and piped into
|
||||||
|
`interpreter-cli` later;
|
||||||
|
2. the day's overall significance level (`Quiet`/`Notable`/
|
||||||
`Significant`/`Major`, plus its rank out of the 4 defined levels,
|
`Significant`/`Major`, plus its rank out of the 4 defined levels,
|
||||||
e.g. `Notable (2/4)`), with a "worth a deeper Celtic Cross look"
|
e.g. `Notable (2/4)`), with a "worth a deeper Celtic Cross look"
|
||||||
line on `Major` days only;
|
line on `Major` days only;
|
||||||
2. up to 5 of today's aspects ranked by score, each naming the sign
|
3. up to 5 of today's aspects ranked by score, each naming the sign
|
||||||
and natal house the transiting planet currently occupies and the
|
and natal house the transiting planet currently occupies and the
|
||||||
sign and house of the natal planet it's aspecting, followed by a
|
sign and house of the natal planet it's aspecting, followed by a
|
||||||
short narrative sentence about what that transiting planet
|
short narrative sentence about what that transiting planet
|
||||||
@@ -291,10 +320,10 @@ A plain-text report, printed in a fixed order —
|
|||||||
governs* (see `CLAUDE.md`'s `narrative.c` bullet) — omitted only if
|
governs* (see `CLAUDE.md`'s `narrative.c` bullet) — omitted only if
|
||||||
the source material has nothing to say for that planet/aspect
|
the source material has nothing to say for that planet/aspect
|
||||||
combination;
|
combination;
|
||||||
3. the full 10-position Celtic Cross spread, in Waite's own drawing
|
4. the full 10-position Celtic Cross spread, in Waite's own drawing
|
||||||
order, each with its card (and orientation), the position's own
|
order, each with its card (and orientation), the position's own
|
||||||
description, and that card's actual meaning;
|
description, and that card's actual meaning;
|
||||||
4. finally, a guidance paragraph tying the day's top transit to the
|
5. finally, a guidance paragraph tying the day's top transit to the
|
||||||
spread's Attitude/Outcome cards, with its opening sentence tailored
|
spread's Attitude/Outcome cards, with its opening sentence tailored
|
||||||
to `day_level` (stronger wording on `Major`, softer on
|
to `day_level` (stronger wording on `Major`, softer on
|
||||||
`Quiet`/`Notable`, falling back to a transit-free reading of the
|
`Quiet`/`Notable`, falling back to a transit-free reading of the
|
||||||
@@ -305,6 +334,8 @@ A plain-text report, printed in a fixed order —
|
|||||||
seen the full spread it references.
|
seen the full spread it references.
|
||||||
|
|
||||||
```
|
```
|
||||||
|
Reading for: 2026-07-16
|
||||||
|
|
||||||
Day significance: Major (4/4)
|
Day significance: Major (4/4)
|
||||||
(a major transit today - worth a deeper Celtic Cross look)
|
(a major transit today - worth a deeper Celtic Cross look)
|
||||||
|
|
||||||
@@ -318,14 +349,14 @@ Top 5 significant transits:
|
|||||||
|
|
||||||
Celtic Cross:
|
Celtic Cross:
|
||||||
1. The Present: The Emperor (Reversed)
|
1. The Present: The Emperor (Reversed)
|
||||||
This covers him: the general influence affecting the matter.
|
This covers them: the general influence affecting the matter.
|
||||||
Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.
|
Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.
|
||||||
2. The Challenge: The Devil
|
2. The Challenge: The Devil
|
||||||
This crosses him: the nature of the obstacle in the matter.
|
This crosses them: the nature of the obstacle in the matter.
|
||||||
Ravage, violence, vehemence, extraordinary efforts, force, fatality.
|
Ravage, violence, vehemence, extraordinary efforts, force, fatality.
|
||||||
...
|
...
|
||||||
7. Himself: The Magician
|
7. Themself: The Magician
|
||||||
His position or attitude in the circumstances.
|
Their position or attitude in the circumstances.
|
||||||
Skill, diplomacy, address, subtlety; self-confidence, will.
|
Skill, diplomacy, address, subtlety; self-confidence, will.
|
||||||
...
|
...
|
||||||
10. The Outcome: Strength
|
10. The Outcome: Strength
|
||||||
@@ -354,6 +385,8 @@ The same report with `--lang de` (same input as the Major example
|
|||||||
above):
|
above):
|
||||||
|
|
||||||
```
|
```
|
||||||
|
Lesung für: 2026-07-16
|
||||||
|
|
||||||
Bedeutung des Tages: Einschneidend (4/4)
|
Bedeutung des Tages: Einschneidend (4/4)
|
||||||
(ein einschneidender Transit heute - ein genauerer Blick auf das Keltische Kreuz lohnt sich)
|
(ein einschneidender Transit heute - ein genauerer Blick auf das Keltische Kreuz lohnt sich)
|
||||||
|
|
||||||
@@ -364,7 +397,7 @@ Top 5 wichtige Transits:
|
|||||||
|
|
||||||
Keltisches Kreuz:
|
Keltisches Kreuz:
|
||||||
1. Die Gegenwart: Der Herrscher (Umgekehrt)
|
1. Die Gegenwart: Der Herrscher (Umgekehrt)
|
||||||
Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
Dies bedeckt die fragende Person: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||||
Wohlwollen, Mitgefühl, Ansehen; auch Verwirrung der Feinde, Behinderung, Unreife.
|
Wohlwollen, Mitgefühl, Ansehen; auch Verwirrung der Feinde, Behinderung, Unreife.
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -390,7 +423,7 @@ HTML output:
|
|||||||
<div class="position">The Present</div>
|
<div class="position">The Present</div>
|
||||||
<img class="reversed" src="img/RWS_Tarot_04_Emperor.jpeg" alt="The Emperor">
|
<img class="reversed" src="img/RWS_Tarot_04_Emperor.jpeg" alt="The Emperor">
|
||||||
<div class="name">The Emperor (Reversed)</div>
|
<div class="name">The Emperor (Reversed)</div>
|
||||||
<div class="desc">This covers him: the general influence affecting the matter.</div>
|
<div class="desc">This covers them: the general influence affecting the matter.</div>
|
||||||
<div class="meaning">Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.</div>
|
<div class="meaning">Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.</div>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
@@ -415,6 +448,7 @@ has the slugs to work with directly.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"date": "2026-07-16",
|
||||||
"day_significance": {
|
"day_significance": {
|
||||||
"level": "major",
|
"level": "major",
|
||||||
"rank": 4,
|
"rank": 4,
|
||||||
@@ -448,6 +482,9 @@ has the slugs to work with directly.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `date` is copied straight through from the input JSON's own top-level
|
||||||
|
`date` field (see `--format json` above) - always language-independent
|
||||||
|
regardless of `--lang`, same as every other slug in this document.
|
||||||
- `significant_transits` has `interp.top_item_count` entries (0-5, same
|
- `significant_transits` has `interp.top_item_count` entries (0-5, same
|
||||||
ranking as `--format text`); `celtic_cross` always has exactly 10, in
|
ranking as `--format text`); `celtic_cross` always has exactly 10, in
|
||||||
`CelticCrossPosition` enum order (same order as `deck-engine`'s own
|
`CelticCrossPosition` enum order (same order as `deck-engine`'s own
|
||||||
|
|||||||
+41
-12
@@ -71,25 +71,53 @@ aspect.square=Quadrat
|
|||||||
aspect.trine=Trigon
|
aspect.trine=Trigon
|
||||||
aspect.opposition=Opposition
|
aspect.opposition=Opposition
|
||||||
|
|
||||||
# --- Positionen des Keltischen Kreuzes (tarot_data.c) ---
|
# --- Positionen des Keltischen Kreuzes (tarot_data.c). Waite spricht die
|
||||||
|
# fragende Person durchgehend mit "ihn/ihm/sein" an; der unsuffigierte
|
||||||
|
# Schlüssel ist eine geschlechtsneutrale Umschreibung (über "die fragende
|
||||||
|
# Person"/"die eigene", ohne Pronomen), die bei nicht angegebenem
|
||||||
|
# Geschlecht verwendet wird, ".male" ist Waites eigener Wortlaut
|
||||||
|
# unverändert, ".female" eine für dieses Projekt geschriebene Umschreibung -
|
||||||
|
# siehe tarot_data.c's Kommentar zu Gender. ---
|
||||||
position.present.name=Die Gegenwart
|
position.present.name=Die Gegenwart
|
||||||
position.present.desc=Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
position.present.desc=Dies bedeckt die fragende Person: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||||
|
position.present.desc.male=Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||||
|
position.present.desc.female=Dies bedeckt sie: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||||
position.challenge.name=Die Herausforderung
|
position.challenge.name=Die Herausforderung
|
||||||
position.challenge.desc=Dies kreuzt ihn: die Art des Hindernisses in der Angelegenheit.
|
position.challenge.desc=Dies kreuzt die fragende Person: die Art des Hindernisses in der Angelegenheit.
|
||||||
|
position.challenge.desc.male=Dies kreuzt ihn: die Art des Hindernisses in der Angelegenheit.
|
||||||
|
position.challenge.desc.female=Dies kreuzt sie: die Art des Hindernisses in der Angelegenheit.
|
||||||
position.crown.name=Die Krone
|
position.crown.name=Die Krone
|
||||||
position.crown.desc=Dies krönt ihn: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
position.crown.desc=Dies krönt die fragende Person: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
||||||
|
position.crown.desc.male=Dies krönt ihn: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
||||||
|
position.crown.desc.female=Dies krönt sie: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
||||||
position.foundation.name=Das Fundament
|
position.foundation.name=Das Fundament
|
||||||
position.foundation.desc=Dies liegt unter ihm: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
position.foundation.desc=Dies liegt unter der fragenden Person: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
||||||
|
position.foundation.desc.male=Dies liegt unter ihm: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
||||||
|
position.foundation.desc.female=Dies liegt unter ihr: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
||||||
position.recent_past.name=Die jüngste Vergangenheit
|
position.recent_past.name=Die jüngste Vergangenheit
|
||||||
position.recent_past.desc=Dies liegt hinter ihm: der Einfluss, der gerade vergeht.
|
position.recent_past.desc=Dies liegt hinter der fragenden Person: der Einfluss, der gerade vergeht.
|
||||||
|
position.recent_past.desc.male=Dies liegt hinter ihm: der Einfluss, der gerade vergeht.
|
||||||
|
position.recent_past.desc.female=Dies liegt hinter ihr: der Einfluss, der gerade vergeht.
|
||||||
position.near_future.name=Die nahe Zukunft
|
position.near_future.name=Die nahe Zukunft
|
||||||
position.near_future.desc=Dies liegt vor ihm: der Einfluss, der jetzt wirksam wird.
|
position.near_future.desc=Dies liegt vor der fragenden Person: der Einfluss, der jetzt wirksam wird.
|
||||||
position.attitude.name=Er selbst
|
position.near_future.desc.male=Dies liegt vor ihm: der Einfluss, der jetzt wirksam wird.
|
||||||
position.attitude.desc=Seine Haltung oder Einstellung in den gegebenen Umständen.
|
position.near_future.desc.female=Dies liegt vor ihr: der Einfluss, der jetzt wirksam wird.
|
||||||
position.environment.name=Sein Haus
|
position.attitude.name=Die fragende Person selbst
|
||||||
position.environment.desc=Sein Umfeld und die darin wirkenden Tendenzen.
|
position.attitude.name.male=Er selbst
|
||||||
|
position.attitude.name.female=Sie selbst
|
||||||
|
position.attitude.desc=Die eigene Haltung oder Einstellung in den gegebenen Umständen.
|
||||||
|
position.attitude.desc.male=Seine Haltung oder Einstellung in den gegebenen Umständen.
|
||||||
|
position.attitude.desc.female=Ihre Haltung oder Einstellung in den gegebenen Umständen.
|
||||||
|
position.environment.name=Das eigene Haus
|
||||||
|
position.environment.name.male=Sein Haus
|
||||||
|
position.environment.name.female=Ihr Haus
|
||||||
|
position.environment.desc=Das eigene Umfeld und die darin wirkenden Tendenzen.
|
||||||
|
position.environment.desc.male=Sein Umfeld und die darin wirkenden Tendenzen.
|
||||||
|
position.environment.desc.female=Ihr Umfeld und die darin wirkenden Tendenzen.
|
||||||
position.hopes_and_fears.name=Hoffnungen und Ängste
|
position.hopes_and_fears.name=Hoffnungen und Ängste
|
||||||
position.hopes_and_fears.desc=Seine Hoffnungen oder Ängste in der Angelegenheit.
|
position.hopes_and_fears.desc=Die eigenen Hoffnungen oder Ängste in der Angelegenheit.
|
||||||
|
position.hopes_and_fears.desc.male=Seine Hoffnungen oder Ängste in der Angelegenheit.
|
||||||
|
position.hopes_and_fears.desc.female=Ihre Hoffnungen oder Ängste in der Angelegenheit.
|
||||||
position.outcome.name=Das Ergebnis
|
position.outcome.name=Das Ergebnis
|
||||||
position.outcome.desc=Was kommen wird: das endgültige Ergebnis der Angelegenheit.
|
position.outcome.desc=Was kommen wird: das endgültige Ergebnis der Angelegenheit.
|
||||||
|
|
||||||
@@ -162,6 +190,7 @@ card.world.upright=Gesicherter Erfolg, Belohnung, Reise, Weg, Auswanderung, Fluc
|
|||||||
card.world.reversed=Trägheit, Erstarrung, Stillstand, Beständigkeit.
|
card.world.reversed=Trägheit, Erstarrung, Stillstand, Beständigkeit.
|
||||||
|
|
||||||
# --- UI-Bezeichnungen des Interpreters (interpreter/src/main.c) ---
|
# --- UI-Bezeichnungen des Interpreters (interpreter/src/main.c) ---
|
||||||
|
interp.reading_date=Lesung für:
|
||||||
interp.day_significance=Bedeutung des Tages:
|
interp.day_significance=Bedeutung des Tages:
|
||||||
interp.day_level.quiet=Ruhig
|
interp.day_level.quiet=Ruhig
|
||||||
interp.day_level.notable=Bemerkenswert
|
interp.day_level.notable=Bemerkenswert
|
||||||
|
|||||||
+40
-12
@@ -79,25 +79,52 @@ aspect.trine=Trine
|
|||||||
aspect.opposition=Opposition
|
aspect.opposition=Opposition
|
||||||
|
|
||||||
# --- Celtic Cross position names/descriptions (tarot_data.c), Waite's
|
# --- Celtic Cross position names/descriptions (tarot_data.c), Waite's
|
||||||
# own wording from "An Ancient Celtic Method of Divination" ---
|
# own wording from "An Ancient Celtic Method of Divination". Waite
|
||||||
|
# addresses the querent as "him" throughout; the unsuffixed key is a
|
||||||
|
# pronoun-neutral paraphrase (singular "they") used when no gender is
|
||||||
|
# configured, the ".male" key is Waite's own wording verbatim, and the
|
||||||
|
# ".female" key is an original paraphrase - see tarot_data.c's own
|
||||||
|
# comment on Gender. ---
|
||||||
position.present.name=The Present
|
position.present.name=The Present
|
||||||
position.present.desc=This covers him: the general influence affecting the matter.
|
position.present.desc=This covers them: the general influence affecting the matter.
|
||||||
|
position.present.desc.male=This covers him: the general influence affecting the matter.
|
||||||
|
position.present.desc.female=This covers her: the general influence affecting the matter.
|
||||||
position.challenge.name=The Challenge
|
position.challenge.name=The Challenge
|
||||||
position.challenge.desc=This crosses him: the nature of the obstacle in the matter.
|
position.challenge.desc=This crosses them: the nature of the obstacle in the matter.
|
||||||
|
position.challenge.desc.male=This crosses him: the nature of the obstacle in the matter.
|
||||||
|
position.challenge.desc.female=This crosses her: the nature of the obstacle in the matter.
|
||||||
position.crown.name=The Crown
|
position.crown.name=The Crown
|
||||||
position.crown.desc=This crowns him: the aim or ideal, the best that can be achieved.
|
position.crown.desc=This crowns them: the aim or ideal, the best that can be achieved.
|
||||||
|
position.crown.desc.male=This crowns him: the aim or ideal, the best that can be achieved.
|
||||||
|
position.crown.desc.female=This crowns her: the aim or ideal, the best that can be achieved.
|
||||||
position.foundation.name=The Foundation
|
position.foundation.name=The Foundation
|
||||||
position.foundation.desc=This is beneath him: the basis of the matter, already actual.
|
position.foundation.desc=This is beneath them: the basis of the matter, already actual.
|
||||||
|
position.foundation.desc.male=This is beneath him: the basis of the matter, already actual.
|
||||||
|
position.foundation.desc.female=This is beneath her: the basis of the matter, already actual.
|
||||||
position.recent_past.name=The Recent Past
|
position.recent_past.name=The Recent Past
|
||||||
position.recent_past.desc=This is behind him: the influence that is just passing away.
|
position.recent_past.desc=This is behind them: the influence that is just passing away.
|
||||||
|
position.recent_past.desc.male=This is behind him: the influence that is just passing away.
|
||||||
|
position.recent_past.desc.female=This is behind her: the influence that is just passing away.
|
||||||
position.near_future.name=The Near Future
|
position.near_future.name=The Near Future
|
||||||
position.near_future.desc=This is before him: the influence now coming into action.
|
position.near_future.desc=This is before them: the influence now coming into action.
|
||||||
position.attitude.name=Himself
|
position.near_future.desc.male=This is before him: the influence now coming into action.
|
||||||
position.attitude.desc=His position or attitude in the circumstances.
|
position.near_future.desc.female=This is before her: the influence now coming into action.
|
||||||
position.environment.name=His House
|
position.attitude.name=Themself
|
||||||
position.environment.desc=His environment and the tendencies at work therein.
|
position.attitude.name.male=Himself
|
||||||
|
position.attitude.name.female=Herself
|
||||||
|
position.attitude.desc=Their position or attitude in the circumstances.
|
||||||
|
position.attitude.desc.male=His position or attitude in the circumstances.
|
||||||
|
position.attitude.desc.female=Her position or attitude in the circumstances.
|
||||||
|
position.environment.name=Their House
|
||||||
|
position.environment.name.male=His House
|
||||||
|
position.environment.name.female=Her House
|
||||||
|
position.environment.desc=Their environment and the tendencies at work therein.
|
||||||
|
position.environment.desc.male=His environment and the tendencies at work therein.
|
||||||
|
position.environment.desc.female=Her environment and the tendencies at work therein.
|
||||||
position.hopes_and_fears.name=Hopes and Fears
|
position.hopes_and_fears.name=Hopes and Fears
|
||||||
position.hopes_and_fears.desc=His hopes or fears in the matter.
|
position.hopes_and_fears.desc=Their hopes or fears in the matter.
|
||||||
|
position.hopes_and_fears.desc.male=His hopes or fears in the matter.
|
||||||
|
position.hopes_and_fears.desc.female=Her hopes or fears in the matter.
|
||||||
position.outcome.name=The Outcome
|
position.outcome.name=The Outcome
|
||||||
position.outcome.desc=What will come: the final result of the matter.
|
position.outcome.desc=What will come: the final result of the matter.
|
||||||
|
|
||||||
@@ -171,6 +198,7 @@ card.world.upright=Assured success, recompense, voyage, route, emigration, fligh
|
|||||||
card.world.reversed=Inertia, fixity, stagnation, permanence.
|
card.world.reversed=Inertia, fixity, stagnation, permanence.
|
||||||
|
|
||||||
# --- Interpreter UI labels (interpreter/src/main.c) ---
|
# --- Interpreter UI labels (interpreter/src/main.c) ---
|
||||||
|
interp.reading_date=Reading for:
|
||||||
interp.day_significance=Day significance:
|
interp.day_significance=Day significance:
|
||||||
interp.day_level.quiet=Quiet
|
interp.day_level.quiet=Quiet
|
||||||
interp.day_level.notable=Notable
|
interp.day_level.notable=Notable
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ birth_time=""
|
|||||||
birth_utc_offset=""
|
birth_utc_offset=""
|
||||||
birth_lat=""
|
birth_lat=""
|
||||||
birth_lon=""
|
birth_lon=""
|
||||||
|
gender="unspecified"
|
||||||
lang="en"
|
lang="en"
|
||||||
|
|
||||||
while IFS='=' read -r key value || [ -n "$key" ]; do
|
while IFS='=' read -r key value || [ -n "$key" ]; do
|
||||||
@@ -43,6 +44,7 @@ while IFS='=' read -r key value || [ -n "$key" ]; do
|
|||||||
birth_utc_offset) birth_utc_offset="$value" ;;
|
birth_utc_offset) birth_utc_offset="$value" ;;
|
||||||
birth_lat) birth_lat="$value" ;;
|
birth_lat) birth_lat="$value" ;;
|
||||||
birth_lon) birth_lon="$value" ;;
|
birth_lon) birth_lon="$value" ;;
|
||||||
|
gender) [ -n "$value" ] && gender="$value" ;;
|
||||||
lang) [ -n "$value" ] && lang="$value" ;;
|
lang) [ -n "$value" ] && lang="$value" ;;
|
||||||
esac
|
esac
|
||||||
done <"$PROPERTIES_FILE"
|
done <"$PROPERTIES_FILE"
|
||||||
@@ -77,5 +79,6 @@ exec "$BINARY" \
|
|||||||
--birth-lon "$birth_lon" \
|
--birth-lon "$birth_lon" \
|
||||||
--date "$transit_date" \
|
--date "$transit_date" \
|
||||||
--format "$format" \
|
--format "$format" \
|
||||||
|
--gender "$gender" \
|
||||||
--lang "$lang" \
|
--lang "$lang" \
|
||||||
--i18n-dir "$SCRIPT_DIR/i18n"
|
--i18n-dir "$SCRIPT_DIR/i18n"
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ birth_time=""
|
|||||||
birth_utc_offset=""
|
birth_utc_offset=""
|
||||||
birth_lat=""
|
birth_lat=""
|
||||||
birth_lon=""
|
birth_lon=""
|
||||||
|
gender="unspecified"
|
||||||
lang="en"
|
lang="en"
|
||||||
|
|
||||||
while IFS='=' read -r key value || [ -n "$key" ]; do
|
while IFS='=' read -r key value || [ -n "$key" ]; do
|
||||||
@@ -55,6 +56,7 @@ while IFS='=' read -r key value || [ -n "$key" ]; do
|
|||||||
birth_utc_offset) birth_utc_offset="$value" ;;
|
birth_utc_offset) birth_utc_offset="$value" ;;
|
||||||
birth_lat) birth_lat="$value" ;;
|
birth_lat) birth_lat="$value" ;;
|
||||||
birth_lon) birth_lon="$value" ;;
|
birth_lon) birth_lon="$value" ;;
|
||||||
|
gender) [ -n "$value" ] && gender="$value" ;;
|
||||||
lang) [ -n "$value" ] && lang="$value" ;;
|
lang) [ -n "$value" ] && lang="$value" ;;
|
||||||
esac
|
esac
|
||||||
done <"$PROPERTIES_FILE"
|
done <"$PROPERTIES_FILE"
|
||||||
@@ -92,4 +94,5 @@ lang="${2:-$lang}"
|
|||||||
--birth-lon "$birth_lon" \
|
--birth-lon "$birth_lon" \
|
||||||
--date "$transit_date" \
|
--date "$transit_date" \
|
||||||
--format json \
|
--format json \
|
||||||
|
--gender "$gender" \
|
||||||
| exec "$INTERPRETER_BINARY" --format "$format" --lang "$lang" --i18n-dir "$SCRIPT_DIR/i18n"
|
| exec "$INTERPRETER_BINARY" --format "$format" --lang "$lang" --i18n-dir "$SCRIPT_DIR/i18n"
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
# (e.g. 2 for Central European Summer Time)
|
# (e.g. 2 for Central European Summer Time)
|
||||||
# birth_lat Birth location latitude, decimal degrees, north positive
|
# birth_lat Birth location latitude, decimal degrees, north positive
|
||||||
# birth_lon Birth location longitude, decimal degrees, east positive
|
# birth_lon Birth location longitude, decimal degrees, east positive
|
||||||
|
# gender Who the reading is for - male, female, or unspecified.
|
||||||
|
# Only affects the Celtic Cross position text's
|
||||||
|
# pronouns (e.g. "His House" vs. "Her House" vs.
|
||||||
|
# "Their House"), never the chart/draw itself.
|
||||||
|
# Optional, defaults to "unspecified"; unrecognized
|
||||||
|
# values fall back to "unspecified" too.
|
||||||
# lang Reading language code - matches a file named
|
# lang Reading language code - matches a file named
|
||||||
# <code>.lang in dist/i18n/ (see engine/i18n/ for the
|
# <code>.lang in dist/i18n/ (see engine/i18n/ for the
|
||||||
# source files, currently "en" and "de"). Optional,
|
# source files, currently "en" and "de"). Optional,
|
||||||
@@ -22,4 +28,5 @@ birth_time=YOUR_BIRTH_TIME_HERE
|
|||||||
birth_utc_offset=YOUR_UTC_OFFSET_HERE
|
birth_utc_offset=YOUR_UTC_OFFSET_HERE
|
||||||
birth_lat=YOUR_LATITUDE_HERE
|
birth_lat=YOUR_LATITUDE_HERE
|
||||||
birth_lon=YOUR_LONGITUDE_HERE
|
birth_lon=YOUR_LONGITUDE_HERE
|
||||||
|
gender=unspecified
|
||||||
lang=en
|
lang=en
|
||||||
|
|||||||
+16
-1
@@ -15,12 +15,16 @@ static void print_usage(const char *prog) {
|
|||||||
"Usage: %s --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM\n"
|
"Usage: %s --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM\n"
|
||||||
" --birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>\n"
|
" --birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>\n"
|
||||||
" [--date YYYY-MM-DDTHH:MM] [--format text|html|json]\n"
|
" [--date YYYY-MM-DDTHH:MM] [--format text|html|json]\n"
|
||||||
|
" [--gender male|female|unspecified]\n"
|
||||||
" [--lang <code>] [--i18n-dir <path>]\n\n"
|
" [--lang <code>] [--i18n-dir <path>]\n\n"
|
||||||
" --seed Tarot seed string; same seed -> same Celtic Cross spread.\n"
|
" --seed Tarot seed string; same seed -> same Celtic Cross spread.\n"
|
||||||
" --birth-date/time Birth date/time in local clock time.\n"
|
" --birth-date/time Birth date/time in local clock time.\n"
|
||||||
" --birth-utc-offset Hours to subtract from local birth time to get UTC (e.g. 2 for CEST).\n"
|
" --birth-utc-offset Hours to subtract from local birth time to get UTC (e.g. 2 for CEST).\n"
|
||||||
" --birth-lat/lon Birth location in decimal degrees (north/east positive).\n"
|
" --birth-lat/lon Birth location in decimal degrees (north/east positive).\n"
|
||||||
" --date UTC moment for today's transits/tarot draw. Defaults to now.\n"
|
" --date UTC moment for today's transits/tarot draw. Defaults to now.\n"
|
||||||
|
" --gender Who the reading is for - only affects the Celtic Cross\n"
|
||||||
|
" position text's pronouns, never the chart/draw itself.\n"
|
||||||
|
" Defaults to unspecified (pronoun-neutral phrasing).\n"
|
||||||
" --format Output format, defaults to text. html links card art via\n"
|
" --format Output format, defaults to text. html links card art via\n"
|
||||||
" img/<file>, i.e. it expects to be saved next to the img/\n"
|
" img/<file>, i.e. it expects to be saved next to the img/\n"
|
||||||
" directory that `make images` copies into dist/. json uses\n"
|
" directory that `make images` copies into dist/. json uses\n"
|
||||||
@@ -63,6 +67,7 @@ int main(int argc, char **argv) {
|
|||||||
const char *birth_utc_offset = NULL, *birth_lat = NULL, *birth_lon = NULL;
|
const char *birth_utc_offset = NULL, *birth_lat = NULL, *birth_lon = NULL;
|
||||||
const char *date_str = NULL, *format_str = "text";
|
const char *date_str = NULL, *format_str = "text";
|
||||||
const char *lang = "en", *i18n_dir_arg = NULL;
|
const char *lang = "en", *i18n_dir_arg = NULL;
|
||||||
|
const char *gender_str = "unspecified";
|
||||||
|
|
||||||
for (int i = 1; i < argc; i++) {
|
for (int i = 1; i < argc; i++) {
|
||||||
const char *arg = argv[i];
|
const char *arg = argv[i];
|
||||||
@@ -75,6 +80,7 @@ int main(int argc, char **argv) {
|
|||||||
else if (strcmp(arg, "--birth-lon") == 0) rc = require_arg(argc, argv, &i, arg, &birth_lon);
|
else if (strcmp(arg, "--birth-lon") == 0) rc = require_arg(argc, argv, &i, arg, &birth_lon);
|
||||||
else if (strcmp(arg, "--date") == 0) rc = require_arg(argc, argv, &i, arg, &date_str);
|
else if (strcmp(arg, "--date") == 0) rc = require_arg(argc, argv, &i, arg, &date_str);
|
||||||
else if (strcmp(arg, "--format") == 0) rc = require_arg(argc, argv, &i, arg, &format_str);
|
else if (strcmp(arg, "--format") == 0) rc = require_arg(argc, argv, &i, arg, &format_str);
|
||||||
|
else if (strcmp(arg, "--gender") == 0) rc = require_arg(argc, argv, &i, arg, &gender_str);
|
||||||
else if (strcmp(arg, "--lang") == 0) rc = require_arg(argc, argv, &i, arg, &lang);
|
else if (strcmp(arg, "--lang") == 0) rc = require_arg(argc, argv, &i, arg, &lang);
|
||||||
else if (strcmp(arg, "--i18n-dir") == 0) rc = require_arg(argc, argv, &i, arg, &i18n_dir_arg);
|
else if (strcmp(arg, "--i18n-dir") == 0) rc = require_arg(argc, argv, &i, arg, &i18n_dir_arg);
|
||||||
else if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
|
else if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
|
||||||
@@ -136,6 +142,15 @@ int main(int argc, char **argv) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Gender gender;
|
||||||
|
if (strcmp(gender_str, "unspecified") == 0) gender = GENDER_UNSPECIFIED;
|
||||||
|
else if (strcmp(gender_str, "male") == 0) gender = GENDER_MALE;
|
||||||
|
else if (strcmp(gender_str, "female") == 0) gender = GENDER_FEMALE;
|
||||||
|
else {
|
||||||
|
fprintf(stderr, "Invalid --gender, expected male, female, or unspecified\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
char i18n_path[512];
|
char i18n_path[512];
|
||||||
if (i18n_dir_arg) {
|
if (i18n_dir_arg) {
|
||||||
snprintf(i18n_path, sizeof i18n_path, "%s/%s.lang", i18n_dir_arg, lang);
|
snprintf(i18n_path, sizeof i18n_path, "%s/%s.lang", i18n_dir_arg, lang);
|
||||||
@@ -148,7 +163,7 @@ int main(int argc, char **argv) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DailyReading reading;
|
DailyReading reading;
|
||||||
reading_generate(seed, &birth, utc_moment, &reading);
|
reading_generate(seed, &birth, gender, utc_moment, &reading);
|
||||||
|
|
||||||
if (format == FORMAT_HTML) {
|
if (format == FORMAT_HTML) {
|
||||||
reading_print_html(&reading, stdout);
|
reading_print_html(&reading, stdout);
|
||||||
|
|||||||
+11
-5
@@ -3,8 +3,10 @@
|
|||||||
|
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
|
|
||||||
void reading_generate(const char *tarot_seed, const BirthData *birth,
|
void reading_generate(const char *tarot_seed, const BirthData *birth, Gender gender,
|
||||||
time_t utc_moment, DailyReading *out) {
|
time_t utc_moment, DailyReading *out) {
|
||||||
|
out->utc_moment = utc_moment;
|
||||||
|
out->gender = gender;
|
||||||
astro_compute_natal_chart(birth, &out->natal);
|
astro_compute_natal_chart(birth, &out->natal);
|
||||||
astro_compute_daily_transits(utc_moment, &out->natal, &out->transits);
|
astro_compute_daily_transits(utc_moment, &out->natal, &out->transits);
|
||||||
tarot_draw_celtic_cross(tarot_seed, &out->spread);
|
tarot_draw_celtic_cross(tarot_seed, &out->spread);
|
||||||
@@ -65,10 +67,10 @@ void reading_print_text(const DailyReading *r, FILE *out) {
|
|||||||
fprintf(out, "===== %s =====\n", ui("ui.celtic_cross", "Celtic Cross"));
|
fprintf(out, "===== %s =====\n", ui("ui.celtic_cross", "Celtic Cross"));
|
||||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||||
const TarotDraw *draw = &r->spread.positions[i];
|
const TarotDraw *draw = &r->spread.positions[i];
|
||||||
fprintf(out, "%-16s %s%s%s\n", tarot_position_name((CelticCrossPosition)i),
|
fprintf(out, "%-16s %s%s%s\n", tarot_position_name((CelticCrossPosition)i, r->gender),
|
||||||
tarot_card_name(draw->card), draw->reversed ? " " : "",
|
tarot_card_name(draw->card), draw->reversed ? " " : "",
|
||||||
draw->reversed ? ui("ui.reversed", "(Reversed)") : "");
|
draw->reversed ? ui("ui.reversed", "(Reversed)") : "");
|
||||||
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i));
|
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i, r->gender));
|
||||||
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
|
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,12 +139,12 @@ void reading_print_html(const DailyReading *r, FILE *out) {
|
|||||||
" <div class=\"desc\">%s</div>\n"
|
" <div class=\"desc\">%s</div>\n"
|
||||||
" <div class=\"meaning\">%s</div>\n"
|
" <div class=\"meaning\">%s</div>\n"
|
||||||
"</div>\n",
|
"</div>\n",
|
||||||
tarot_position_name((CelticCrossPosition)i),
|
tarot_position_name((CelticCrossPosition)i, r->gender),
|
||||||
draw->reversed ? "reversed" : "",
|
draw->reversed ? "reversed" : "",
|
||||||
tarot_card_image_file(draw->card), tarot_card_name(draw->card),
|
tarot_card_image_file(draw->card), tarot_card_name(draw->card),
|
||||||
tarot_card_name(draw->card), draw->reversed ? " " : "",
|
tarot_card_name(draw->card), draw->reversed ? " " : "",
|
||||||
draw->reversed ? ui("ui.reversed", "(Reversed)") : "",
|
draw->reversed ? ui("ui.reversed", "(Reversed)") : "",
|
||||||
tarot_position_description((CelticCrossPosition)i),
|
tarot_position_description((CelticCrossPosition)i, r->gender),
|
||||||
tarot_card_meaning(draw->card, draw->reversed));
|
tarot_card_meaning(draw->card, draw->reversed));
|
||||||
}
|
}
|
||||||
fprintf(out, "</div>\n</body></html>\n");
|
fprintf(out, "</div>\n</body></html>\n");
|
||||||
@@ -160,6 +162,10 @@ static void print_body_position_json(FILE *out, const char *indent, Body body,
|
|||||||
void reading_print_json(const DailyReading *r, FILE *out) {
|
void reading_print_json(const DailyReading *r, FILE *out) {
|
||||||
fprintf(out, "{\n");
|
fprintf(out, "{\n");
|
||||||
|
|
||||||
|
struct tm *utc = gmtime(&r->utc_moment);
|
||||||
|
fprintf(out, " \"date\": \"%04d-%02d-%02d\",\n", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
|
||||||
|
fprintf(out, " \"gender\": \"%s\",\n", tarot_gender_slug(r->gender));
|
||||||
|
|
||||||
fprintf(out, " \"natal\": {\n \"bodies\": [\n");
|
fprintf(out, " \"natal\": {\n \"bodies\": [\n");
|
||||||
for (int b = 0; b < NUM_BODIES; b++) {
|
for (int b = 0; b < NUM_BODIES; b++) {
|
||||||
print_body_position_json(out, " ", (Body)b, &r->natal.bodies[b]);
|
print_body_position_json(out, " ", (Body)b, &r->natal.bodies[b]);
|
||||||
|
|||||||
+11
-1
@@ -15,6 +15,16 @@
|
|||||||
#include "tarot.h"
|
#include "tarot.h"
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
|
/* The moment passed to reading_generate() - the day this reading is
|
||||||
|
* for, not when it happened to be generated. Set by reading_generate()
|
||||||
|
* itself, so every caller (CLI, watch, reading_load_json() on the
|
||||||
|
* interpreter side) gets it for free without threading it through
|
||||||
|
* separately. */
|
||||||
|
time_t utc_moment;
|
||||||
|
/* Who the reading is for - only affects the pronouns in the Celtic
|
||||||
|
* Cross position text (see tarot.h's Gender), set by reading_generate()
|
||||||
|
* the same way utc_moment is, so every caller gets it for free. */
|
||||||
|
Gender gender;
|
||||||
NatalChart natal;
|
NatalChart natal;
|
||||||
DailyTransits transits;
|
DailyTransits transits;
|
||||||
CelticCrossSpread spread;
|
CelticCrossSpread spread;
|
||||||
@@ -22,7 +32,7 @@ typedef struct {
|
|||||||
|
|
||||||
/* The real API: this is the only function the watchapp needs to call.
|
/* The real API: this is the only function the watchapp needs to call.
|
||||||
* It walks the returned struct directly to lay out its own screens. */
|
* It walks the returned struct directly to lay out its own screens. */
|
||||||
void reading_generate(const char *tarot_seed, const BirthData *birth,
|
void reading_generate(const char *tarot_seed, const BirthData *birth, Gender gender,
|
||||||
time_t utc_moment, DailyReading *out);
|
time_t utc_moment, DailyReading *out);
|
||||||
|
|
||||||
/* Desktop-only output formats for inspecting a reading before any watch
|
/* Desktop-only output formats for inspecting a reading before any watch
|
||||||
|
|||||||
+14
-2
@@ -52,6 +52,17 @@ typedef struct {
|
|||||||
bool reversed;
|
bool reversed;
|
||||||
} TarotDraw;
|
} TarotDraw;
|
||||||
|
|
||||||
|
/* Who the reading is for - affects only the pronouns in the Celtic
|
||||||
|
* Cross position text below (Waite's original wording addresses the
|
||||||
|
* querent as "him"), never anything about the tarot draw or astrology
|
||||||
|
* math itself. GENDER_UNSPECIFIED is the default and renders
|
||||||
|
* pronoun-neutral phrasing (singular "they" in English). */
|
||||||
|
typedef enum {
|
||||||
|
GENDER_UNSPECIFIED = 0,
|
||||||
|
GENDER_MALE,
|
||||||
|
GENDER_FEMALE
|
||||||
|
} Gender;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
/* Indexed by CelticCrossPosition. */
|
/* Indexed by CelticCrossPosition. */
|
||||||
TarotDraw positions[TAROT_SPREAD_SIZE];
|
TarotDraw positions[TAROT_SPREAD_SIZE];
|
||||||
@@ -67,8 +78,8 @@ const char *tarot_card_name(TarotCard card);
|
|||||||
const char *tarot_card_image_file(TarotCard card); /* matches res/img/ */
|
const char *tarot_card_image_file(TarotCard card); /* matches res/img/ */
|
||||||
const char *tarot_card_meaning(TarotCard card, bool reversed);
|
const char *tarot_card_meaning(TarotCard card, bool reversed);
|
||||||
|
|
||||||
const char *tarot_position_name(CelticCrossPosition position);
|
const char *tarot_position_name(CelticCrossPosition position, Gender gender);
|
||||||
const char *tarot_position_description(CelticCrossPosition position);
|
const char *tarot_position_description(CelticCrossPosition position, Gender gender);
|
||||||
|
|
||||||
/* Stable, language-independent identifiers (e.g. "high_priestess",
|
/* Stable, language-independent identifiers (e.g. "high_priestess",
|
||||||
* "wheel_of_fortune", "recent_past") - the same strings i18n.c's lookup
|
* "wheel_of_fortune", "recent_past") - the same strings i18n.c's lookup
|
||||||
@@ -76,5 +87,6 @@ const char *tarot_position_description(CelticCrossPosition position);
|
|||||||
* (reading_print_json) that must stay identical regardless of --lang. */
|
* (reading_print_json) that must stay identical regardless of --lang. */
|
||||||
const char *tarot_card_slug(TarotCard card);
|
const char *tarot_card_slug(TarotCard card);
|
||||||
const char *tarot_position_slug(CelticCrossPosition position);
|
const char *tarot_position_slug(CelticCrossPosition position);
|
||||||
|
const char *tarot_gender_slug(Gender gender); /* "unspecified"/"male"/"female" */
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
+112
-34
@@ -130,52 +130,97 @@ static const TarotCardInfo k_cards[TAROT_DECK_SIZE] = {
|
|||||||
|
|
||||||
/* Position names and one-line descriptions follow Waite's own account
|
/* Position names and one-line descriptions follow Waite's own account
|
||||||
* of "An Ancient Celtic Method of Divination" (Part III §7), in his
|
* of "An Ancient Celtic Method of Divination" (Part III §7), in his
|
||||||
* original drawing order. */
|
* original drawing order. Waite addresses the querent as "him"
|
||||||
|
* throughout; that wording is kept verbatim as the GENDER_MALE variant
|
||||||
|
* below, and GENDER_FEMALE/GENDER_UNSPECIFIED are original paraphrases
|
||||||
|
* written for this project (not Waite's own text) so the reading can
|
||||||
|
* address the person it's actually for. Each array is indexed by
|
||||||
|
* Gender (tarot.h): [GENDER_UNSPECIFIED], [GENDER_MALE], [GENDER_FEMALE]. */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
const char *name;
|
const char *name[3];
|
||||||
const char *description;
|
const char *description[3];
|
||||||
} PositionInfo;
|
} PositionInfo;
|
||||||
|
|
||||||
static const PositionInfo k_positions[TAROT_SPREAD_SIZE] = {
|
static const PositionInfo k_positions[TAROT_SPREAD_SIZE] = {
|
||||||
[POSITION_PRESENT] = {
|
[POSITION_PRESENT] = {
|
||||||
"The Present",
|
{ "The Present", "The Present", "The Present" },
|
||||||
"This covers him: the general influence affecting the matter."
|
{
|
||||||
|
"This covers them: the general influence affecting the matter.",
|
||||||
|
"This covers him: the general influence affecting the matter.",
|
||||||
|
"This covers her: the general influence affecting the matter.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_CHALLENGE] = {
|
[POSITION_CHALLENGE] = {
|
||||||
"The Challenge",
|
{ "The Challenge", "The Challenge", "The Challenge" },
|
||||||
"This crosses him: the nature of the obstacle in the matter."
|
{
|
||||||
|
"This crosses them: the nature of the obstacle in the matter.",
|
||||||
|
"This crosses him: the nature of the obstacle in the matter.",
|
||||||
|
"This crosses her: the nature of the obstacle in the matter.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_CROWN] = {
|
[POSITION_CROWN] = {
|
||||||
"The Crown",
|
{ "The Crown", "The Crown", "The Crown" },
|
||||||
"This crowns him: the aim or ideal, the best that can be achieved."
|
{
|
||||||
|
"This crowns them: the aim or ideal, the best that can be achieved.",
|
||||||
|
"This crowns him: the aim or ideal, the best that can be achieved.",
|
||||||
|
"This crowns her: the aim or ideal, the best that can be achieved.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_FOUNDATION] = {
|
[POSITION_FOUNDATION] = {
|
||||||
"The Foundation",
|
{ "The Foundation", "The Foundation", "The Foundation" },
|
||||||
"This is beneath him: the basis of the matter, already actual."
|
{
|
||||||
|
"This is beneath them: the basis of the matter, already actual.",
|
||||||
|
"This is beneath him: the basis of the matter, already actual.",
|
||||||
|
"This is beneath her: the basis of the matter, already actual.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_RECENT_PAST] = {
|
[POSITION_RECENT_PAST] = {
|
||||||
"The Recent Past",
|
{ "The Recent Past", "The Recent Past", "The Recent Past" },
|
||||||
"This is behind him: the influence that is just passing away."
|
{
|
||||||
|
"This is behind them: the influence that is just passing away.",
|
||||||
|
"This is behind him: the influence that is just passing away.",
|
||||||
|
"This is behind her: the influence that is just passing away.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_NEAR_FUTURE] = {
|
[POSITION_NEAR_FUTURE] = {
|
||||||
"The Near Future",
|
{ "The Near Future", "The Near Future", "The Near Future" },
|
||||||
"This is before him: the influence now coming into action."
|
{
|
||||||
|
"This is before them: the influence now coming into action.",
|
||||||
|
"This is before him: the influence now coming into action.",
|
||||||
|
"This is before her: the influence now coming into action.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_ATTITUDE] = {
|
[POSITION_ATTITUDE] = {
|
||||||
"Himself",
|
{ "Themself", "Himself", "Herself" },
|
||||||
"His position or attitude in the circumstances."
|
{
|
||||||
|
"Their position or attitude in the circumstances.",
|
||||||
|
"His position or attitude in the circumstances.",
|
||||||
|
"Her position or attitude in the circumstances.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_ENVIRONMENT] = {
|
[POSITION_ENVIRONMENT] = {
|
||||||
"His House",
|
{ "Their House", "His House", "Her House" },
|
||||||
"His environment and the tendencies at work therein."
|
{
|
||||||
|
"Their environment and the tendencies at work therein.",
|
||||||
|
"His environment and the tendencies at work therein.",
|
||||||
|
"Her environment and the tendencies at work therein.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_HOPES_AND_FEARS] = {
|
[POSITION_HOPES_AND_FEARS] = {
|
||||||
"Hopes and Fears",
|
{ "Hopes and Fears", "Hopes and Fears", "Hopes and Fears" },
|
||||||
"His hopes or fears in the matter."
|
{
|
||||||
|
"Their hopes or fears in the matter.",
|
||||||
|
"His hopes or fears in the matter.",
|
||||||
|
"Her hopes or fears in the matter.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_OUTCOME] = {
|
[POSITION_OUTCOME] = {
|
||||||
"The Outcome",
|
{ "The Outcome", "The Outcome", "The Outcome" },
|
||||||
"What will come: the final result of the matter."
|
{
|
||||||
|
"What will come: the final result of the matter.",
|
||||||
|
"What will come: the final result of the matter.",
|
||||||
|
"What will come: the final result of the matter.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -229,13 +274,44 @@ static const char *card_field(const char *slug, const char *field, const char *f
|
|||||||
return i18n_get(key, fallback);
|
return i18n_get(key, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
static const char *position_field(const char *slug, const char *field, const char *fallback) {
|
/* Suffix appended to the i18n key for the male/female variants - the
|
||||||
char key[64];
|
* unspecified/neutral variant uses the plain, unsuffixed key so it
|
||||||
strcpy(key, "position.");
|
* matches the field name callers already know (e.g. "position.
|
||||||
strcat(key, slug);
|
* attitude.name"), same convention as card_field()'s upright/reversed
|
||||||
strcat(key, ".");
|
* field names above. */
|
||||||
strcat(key, field);
|
static const char *const k_gender_suffix[3] = {
|
||||||
return i18n_get(key, fallback);
|
[GENDER_UNSPECIFIED] = "",
|
||||||
|
[GENDER_MALE] = ".male",
|
||||||
|
[GENDER_FEMALE] = ".female",
|
||||||
|
};
|
||||||
|
|
||||||
|
static const char *const k_gender_slug[3] = {
|
||||||
|
[GENDER_UNSPECIFIED] = "unspecified",
|
||||||
|
[GENDER_MALE] = "male",
|
||||||
|
[GENDER_FEMALE] = "female",
|
||||||
|
};
|
||||||
|
|
||||||
|
static const char *position_field(const char *slug, const char *field, Gender gender,
|
||||||
|
const char *fallback) {
|
||||||
|
char neutral_key[80];
|
||||||
|
strcpy(neutral_key, "position.");
|
||||||
|
strcat(neutral_key, slug);
|
||||||
|
strcat(neutral_key, ".");
|
||||||
|
strcat(neutral_key, field);
|
||||||
|
if (gender == GENDER_UNSPECIFIED) return i18n_get(neutral_key, fallback);
|
||||||
|
|
||||||
|
/* Not every position's text actually varies by gender (e.g. "The
|
||||||
|
* Outcome" has no pronoun) - the .lang files only define a ".male"/
|
||||||
|
* ".female" key where the wording differs. So look up the neutral key
|
||||||
|
* first and use *that* (translated, if a catalog is loaded) as the
|
||||||
|
* fallback for the gendered key, rather than jumping straight past a
|
||||||
|
* loaded translation to the hardcoded English `fallback` whenever the
|
||||||
|
* gendered key happens to be absent. */
|
||||||
|
const char *neutral_text = i18n_get(neutral_key, fallback);
|
||||||
|
char key[88];
|
||||||
|
strcpy(key, neutral_key);
|
||||||
|
strcat(key, k_gender_suffix[gender]);
|
||||||
|
return i18n_get(key, neutral_text);
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *tarot_card_name(TarotCard card) {
|
const char *tarot_card_name(TarotCard card) {
|
||||||
@@ -252,13 +328,15 @@ const char *tarot_card_meaning(TarotCard card, bool reversed) {
|
|||||||
reversed ? k_cards[card].reversed : k_cards[card].upright);
|
reversed ? k_cards[card].reversed : k_cards[card].upright);
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *tarot_position_name(CelticCrossPosition position) {
|
const char *tarot_position_name(CelticCrossPosition position, Gender gender) {
|
||||||
return position_field(k_position_slug[position], "name", k_positions[position].name);
|
return position_field(k_position_slug[position], "name", gender, k_positions[position].name[gender]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *tarot_position_description(CelticCrossPosition position) {
|
const char *tarot_position_description(CelticCrossPosition position, Gender gender) {
|
||||||
return position_field(k_position_slug[position], "desc", k_positions[position].description);
|
return position_field(k_position_slug[position], "desc", gender,
|
||||||
|
k_positions[position].description[gender]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *tarot_card_slug(TarotCard card) { return k_card_slug[card]; }
|
const char *tarot_card_slug(TarotCard card) { return k_card_slug[card]; }
|
||||||
const char *tarot_position_slug(CelticCrossPosition position) { return k_position_slug[position]; }
|
const char *tarot_position_slug(CelticCrossPosition position) { return k_position_slug[position]; }
|
||||||
|
const char *tarot_gender_slug(Gender gender) { return k_gender_slug[gender]; }
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ static void test_reading_generate_smoke(void) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
DailyReading r1, r2;
|
DailyReading r1, r2;
|
||||||
reading_generate("smoke-test-seed", &birth, 1751500000, &r1);
|
reading_generate("smoke-test-seed", &birth, GENDER_UNSPECIFIED, 1751500000, &r1);
|
||||||
reading_generate("smoke-test-seed", &birth, 1751500000, &r2);
|
reading_generate("smoke-test-seed", &birth, GENDER_UNSPECIFIED, 1751500000, &r2);
|
||||||
|
|
||||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||||
assert(r1.spread.positions[i].card == r2.spread.positions[i].card);
|
assert(r1.spread.positions[i].card == r2.spread.positions[i].card);
|
||||||
@@ -103,7 +103,7 @@ static void test_reading_print_json_shape(void) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
DailyReading r;
|
DailyReading r;
|
||||||
reading_generate("smoke-test-seed", &birth, 1751500000, &r);
|
reading_generate("smoke-test-seed", &birth, GENDER_FEMALE, 1751500000, &r);
|
||||||
|
|
||||||
FILE *tmp = tmpfile();
|
FILE *tmp = tmpfile();
|
||||||
assert(tmp);
|
assert(tmp);
|
||||||
@@ -120,6 +120,8 @@ static void test_reading_print_json_shape(void) {
|
|||||||
* a stray astro_*_name()/tarot_*_name() call leaking translated text
|
* a stray astro_*_name()/tarot_*_name() call leaking translated text
|
||||||
* into what must stay language-independent regardless of --lang). */
|
* into what must stay language-independent regardless of --lang). */
|
||||||
assert(buf[0] == '{');
|
assert(buf[0] == '{');
|
||||||
|
assert(strstr(buf, "\"date\": \"2025-07-02\"") != NULL); /* UTC date of the fixed 1751500000 seed */
|
||||||
|
assert(strstr(buf, "\"gender\": \"female\"") != NULL);
|
||||||
assert(strstr(buf, "\"natal\"") != NULL);
|
assert(strstr(buf, "\"natal\"") != NULL);
|
||||||
assert(strstr(buf, "\"transits\"") != NULL);
|
assert(strstr(buf, "\"transits\"") != NULL);
|
||||||
assert(strstr(buf, "\"spread\"") != NULL);
|
assert(strstr(buf, "\"spread\"") != NULL);
|
||||||
@@ -154,7 +156,7 @@ static void test_i18n_fallback_and_translation(void) {
|
|||||||
assert(strcmp(astro_moon_phase_name(MOON_FULL), "Vollmond") == 0);
|
assert(strcmp(astro_moon_phase_name(MOON_FULL), "Vollmond") == 0);
|
||||||
assert(strcmp(astro_aspect_name(ASPECT_TRINE), "Trigon") == 0);
|
assert(strcmp(astro_aspect_name(ASPECT_TRINE), "Trigon") == 0);
|
||||||
assert(strcmp(tarot_card_name(CARD_WORLD), "Die Welt") == 0);
|
assert(strcmp(tarot_card_name(CARD_WORLD), "Die Welt") == 0);
|
||||||
assert(strcmp(tarot_position_name(POSITION_OUTCOME), "Das Ergebnis") == 0);
|
assert(strcmp(tarot_position_name(POSITION_OUTCOME, GENDER_UNSPECIFIED), "Das Ergebnis") == 0);
|
||||||
|
|
||||||
/* Loading a file that doesn't exist fails and leaves the previously
|
/* Loading a file that doesn't exist fails and leaves the previously
|
||||||
* loaded catalog in place, rather than silently clearing it. */
|
* loaded catalog in place, rather than silently clearing it. */
|
||||||
@@ -169,12 +171,45 @@ static void test_i18n_fallback_and_translation(void) {
|
|||||||
printf("PASS test_i18n_fallback_and_translation\n");
|
printf("PASS test_i18n_fallback_and_translation\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_gendered_position_text(void) {
|
||||||
|
/* English catalog (loaded by the previous test): "Attitude" is the one
|
||||||
|
* position whose name itself carries a pronoun. */
|
||||||
|
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_UNSPECIFIED), "Themself") == 0);
|
||||||
|
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_MALE), "Himself") == 0);
|
||||||
|
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_FEMALE), "Herself") == 0);
|
||||||
|
assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_UNSPECIFIED), "them") != NULL);
|
||||||
|
assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_MALE), "him") != NULL);
|
||||||
|
assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_FEMALE), "her") != NULL);
|
||||||
|
|
||||||
|
/* The Outcome position has no pronoun in Waite's own text - identical
|
||||||
|
* across all three genders. */
|
||||||
|
assert(strcmp(tarot_position_description(POSITION_OUTCOME, GENDER_MALE),
|
||||||
|
tarot_position_description(POSITION_OUTCOME, GENDER_FEMALE)) == 0);
|
||||||
|
assert(strcmp(tarot_position_description(POSITION_OUTCOME, GENDER_MALE),
|
||||||
|
tarot_position_description(POSITION_OUTCOME, GENDER_UNSPECIFIED)) == 0);
|
||||||
|
|
||||||
|
bool loaded = i18n_load("engine/i18n/de.lang");
|
||||||
|
assert(loaded);
|
||||||
|
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_MALE), "Er selbst") == 0);
|
||||||
|
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_FEMALE), "Sie selbst") == 0);
|
||||||
|
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_UNSPECIFIED),
|
||||||
|
"Die fragende Person selbst") == 0);
|
||||||
|
assert(strcmp(tarot_position_description(POSITION_ENVIRONMENT, GENDER_MALE),
|
||||||
|
"Sein Umfeld und die darin wirkenden Tendenzen.") == 0);
|
||||||
|
|
||||||
|
loaded = i18n_load("engine/i18n/en.lang"); /* leave the catalog in a known state */
|
||||||
|
assert(loaded);
|
||||||
|
|
||||||
|
printf("PASS test_gendered_position_text\n");
|
||||||
|
}
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
test_tarot_determinism();
|
test_tarot_determinism();
|
||||||
test_natal_sun_sign();
|
test_natal_sun_sign();
|
||||||
test_reading_generate_smoke();
|
test_reading_generate_smoke();
|
||||||
test_reading_print_json_shape();
|
test_reading_print_json_shape();
|
||||||
test_i18n_fallback_and_translation();
|
test_i18n_fallback_and_translation();
|
||||||
|
test_gendered_position_text();
|
||||||
printf("All smoke tests passed.\n");
|
printf("All smoke tests passed.\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+129
-64
@@ -126,6 +126,15 @@ static const char *sign_display_name(ZodiacSign sign) {
|
|||||||
return ui(key, fallback[sign]);
|
return ui(key, fallback[sign]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "YYYY-MM-DD" for reading->utc_moment - the day this reading is for
|
||||||
|
* (see reading_load_json()/parse_date() in reading_io.c), not today's
|
||||||
|
* real date if the two ever differ (e.g. reading.json was generated
|
||||||
|
* earlier and interpreted later). */
|
||||||
|
static void format_date(time_t utc_moment, char *out, size_t out_size) {
|
||||||
|
struct tm *utc = gmtime(&utc_moment);
|
||||||
|
snprintf(out, out_size, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
|
||||||
|
}
|
||||||
|
|
||||||
static const char *day_level_name(DaySignificance level) {
|
static const char *day_level_name(DaySignificance level) {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case DAY_QUIET: return ui("interp.day_level.quiet", "Quiet");
|
case DAY_QUIET: return ui("interp.day_level.quiet", "Quiet");
|
||||||
@@ -179,6 +188,32 @@ static char *capture_guidance(const DailyInterpretation *interp, const CelticCro
|
|||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Prints one significant-transit item's full detail (title line with
|
||||||
|
* sign/house/orb/score, plus its narrative sentence) - shared between
|
||||||
|
* the "Significant Transits" list and the day's top item, which is
|
||||||
|
* repeated in full just above the guidance paragraph (see
|
||||||
|
* interp_print_text) so the reader has that same detail in view right
|
||||||
|
* next to the guidance text it informs. `index` is 1-based and prefixes
|
||||||
|
* the line with "N. "; pass 0 to omit it, for the repeated top item,
|
||||||
|
* which isn't part of the numbered list. */
|
||||||
|
static void print_transit_item_text(FILE *out, const DailyReading *reading,
|
||||||
|
const SignificantItem *item, int index) {
|
||||||
|
const Aspect *a = &item->aspect;
|
||||||
|
if (index > 0) fprintf(out, " %d. ", index);
|
||||||
|
fprintf(out, "%s %s", ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet));
|
||||||
|
print_body_in_sign_text(out, &reading->transits.bodies[a->transiting_planet]);
|
||||||
|
fprintf(out, " %s %s %s", aspect_display_name(a->type), ui("ui.natal", "natal"),
|
||||||
|
body_display_name(a->natal_planet));
|
||||||
|
print_body_in_sign_text(out, &reading->natal.bodies[a->natal_planet]);
|
||||||
|
fprintf(out, " (%s %.1f\xc2\xb0, %s %.2f)\n", ui("ui.orb", "orb"), a->orb,
|
||||||
|
ui("interp.score", "score"), item->score);
|
||||||
|
|
||||||
|
char narrative_buf[NARRATIVE_TEXT_MAX];
|
||||||
|
narrative_write(narrative_buf, sizeof narrative_buf, " ", a,
|
||||||
|
reading->transits.bodies[a->transiting_planet].house);
|
||||||
|
fputs(narrative_buf, out);
|
||||||
|
}
|
||||||
|
|
||||||
static void print_top_transits_header(FILE *out, int count) {
|
static void print_top_transits_header(FILE *out, int count) {
|
||||||
if (count == 1) {
|
if (count == 1) {
|
||||||
fprintf(out, "%s\n", ui("interp.top_transits.one", "Top significant transit:"));
|
fprintf(out, "%s\n", ui("interp.top_transits.one", "Top significant transit:"));
|
||||||
@@ -191,18 +226,22 @@ static void print_top_transits_header(FILE *out, int count) {
|
|||||||
|
|
||||||
/* ===== --format text ===== */
|
/* ===== --format text ===== */
|
||||||
|
|
||||||
static void print_celtic_cross_text(FILE *out, const CelticCrossSpread *spread) {
|
static void print_celtic_cross_text(FILE *out, const CelticCrossSpread *spread, Gender gender) {
|
||||||
fprintf(out, "%s:\n", ui("ui.celtic_cross", "Celtic Cross"));
|
fprintf(out, "%s:\n", ui("ui.celtic_cross", "Celtic Cross"));
|
||||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||||
const TarotDraw *draw = &spread->positions[i];
|
const TarotDraw *draw = &spread->positions[i];
|
||||||
fprintf(out, " %d. %s: %s%s\n", i + 1, tarot_position_name((CelticCrossPosition)i),
|
fprintf(out, " %d. %s: %s%s\n", i + 1, tarot_position_name((CelticCrossPosition)i, gender),
|
||||||
tarot_card_name(draw->card), reversed_marker(draw->reversed));
|
tarot_card_name(draw->card), reversed_marker(draw->reversed));
|
||||||
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i));
|
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i, gender));
|
||||||
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
|
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void interp_print_text(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
|
static void interp_print_text(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
|
||||||
|
char date_buf[32];
|
||||||
|
format_date(reading->utc_moment, date_buf, sizeof date_buf);
|
||||||
|
fprintf(out, "%s %s\n\n", ui("interp.reading_date", "Reading for:"), date_buf);
|
||||||
|
|
||||||
fprintf(out, "%s %s (%d/%d)\n", ui("interp.day_significance", "Day significance:"),
|
fprintf(out, "%s %s (%d/%d)\n", ui("interp.day_significance", "Day significance:"),
|
||||||
day_level_name(interp->day_level), interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
|
day_level_name(interp->day_level), interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
|
||||||
if (interpretation_deserves_framing(interp)) {
|
if (interpretation_deserves_framing(interp)) {
|
||||||
@@ -211,41 +250,69 @@ static void interp_print_text(FILE *out, const DailyReading *reading, const Dail
|
|||||||
}
|
}
|
||||||
fprintf(out, "\n");
|
fprintf(out, "\n");
|
||||||
|
|
||||||
|
fprintf(out, "%s\n", ui("interp.heading_guidance", "Guidance"));
|
||||||
|
if (interp->top_item_count > 0) {
|
||||||
|
print_transit_item_text(out, reading, &interp->top_items[0], 0);
|
||||||
|
}
|
||||||
|
char guidance_buf[GUIDANCE_TEXT_MAX];
|
||||||
|
guidance_write(guidance_buf, sizeof guidance_buf, "", interp, &reading->spread);
|
||||||
|
fputs(guidance_buf, out);
|
||||||
|
fprintf(out, "\n");
|
||||||
|
|
||||||
if (interp->top_item_count == 0) {
|
if (interp->top_item_count == 0) {
|
||||||
fprintf(out, "%s\n", ui("interp.no_notable_transits", "No notable transits today."));
|
fprintf(out, "%s\n", ui("interp.no_notable_transits", "No notable transits today."));
|
||||||
} else {
|
} else {
|
||||||
print_top_transits_header(out, interp->top_item_count);
|
print_top_transits_header(out, interp->top_item_count);
|
||||||
for (int i = 0; i < interp->top_item_count; i++) {
|
for (int i = 0; i < interp->top_item_count; i++) {
|
||||||
const SignificantItem *item = &interp->top_items[i];
|
print_transit_item_text(out, reading, &interp->top_items[i], i + 1);
|
||||||
const Aspect *a = &item->aspect;
|
|
||||||
|
|
||||||
fprintf(out, " %d. %s %s", i + 1, ui("ui.transiting", "Transiting"),
|
|
||||||
body_display_name(a->transiting_planet));
|
|
||||||
print_body_in_sign_text(out, &reading->transits.bodies[a->transiting_planet]);
|
|
||||||
fprintf(out, " %s %s %s", aspect_display_name(a->type), ui("ui.natal", "natal"),
|
|
||||||
body_display_name(a->natal_planet));
|
|
||||||
print_body_in_sign_text(out, &reading->natal.bodies[a->natal_planet]);
|
|
||||||
fprintf(out, " (%s %.1f\xc2\xb0, %s %.2f)\n", ui("ui.orb", "orb"), a->orb,
|
|
||||||
ui("interp.score", "score"), item->score);
|
|
||||||
|
|
||||||
char narrative_buf[NARRATIVE_TEXT_MAX];
|
|
||||||
narrative_write(narrative_buf, sizeof narrative_buf, " ", a,
|
|
||||||
reading->transits.bodies[a->transiting_planet].house);
|
|
||||||
fputs(narrative_buf, out);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fprintf(out, "\n");
|
fprintf(out, "\n");
|
||||||
|
|
||||||
print_celtic_cross_text(out, &reading->spread);
|
print_celtic_cross_text(out, &reading->spread, reading->gender);
|
||||||
fprintf(out, "\n");
|
|
||||||
|
|
||||||
char guidance_buf[GUIDANCE_TEXT_MAX];
|
|
||||||
guidance_write(guidance_buf, sizeof guidance_buf, "", interp, &reading->spread);
|
|
||||||
fputs(guidance_buf, out);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== --format html ===== */
|
/* ===== --format html ===== */
|
||||||
|
|
||||||
|
/* HTML counterpart of print_transit_item_text() - same shared-between-
|
||||||
|
* the-repeated-top-item-and-the-full-list purpose, one table row (title
|
||||||
|
* + orb/score) plus a narrative row if non-empty. `index` is 1-based;
|
||||||
|
* pass 0 to omit the "N." cell, for the repeated top item. */
|
||||||
|
static void print_transit_item_html(FILE *out, const DailyReading *reading,
|
||||||
|
const SignificantItem *item, int index) {
|
||||||
|
const Aspect *a = &item->aspect;
|
||||||
|
char index_buf[16] = "";
|
||||||
|
if (index > 0) snprintf(index_buf, sizeof index_buf, "%d.", index);
|
||||||
|
char *text = capture_narrative(a, reading->transits.bodies[a->transiting_planet].house);
|
||||||
|
|
||||||
|
fprintf(out, "<tr><td>%s</td><td>%s %s %s %s %s</td>"
|
||||||
|
"<td>%s %.1f°, %s %.2f</td></tr>\n",
|
||||||
|
index_buf, ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet),
|
||||||
|
aspect_display_name(a->type), ui("ui.natal", "natal"), body_display_name(a->natal_planet),
|
||||||
|
ui("ui.orb", "orb"), a->orb, ui("interp.score", "score"), item->score);
|
||||||
|
if (text[0] != '\0') fprintf(out, "<tr><td></td><td colspan=\"2\">%s</td></tr>\n", text);
|
||||||
|
free(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One Celtic Cross card as a ".card" div - shared between the
|
||||||
|
* Attitude/Outcome preview (right after guidance) and the full spread
|
||||||
|
* further down, so both render identically. */
|
||||||
|
static void print_card_html(FILE *out, CelticCrossPosition position, const TarotDraw *draw,
|
||||||
|
Gender gender) {
|
||||||
|
fprintf(out,
|
||||||
|
"<div class=\"card\">\n"
|
||||||
|
" <div class=\"position\">%s</div>\n"
|
||||||
|
" <img class=\"%s\" src=\"img/%s\" alt=\"%s\">\n"
|
||||||
|
" <div class=\"name\">%s%s</div>\n"
|
||||||
|
" <div class=\"desc\">%s</div>\n"
|
||||||
|
" <div class=\"meaning\">%s</div>\n"
|
||||||
|
"</div>\n",
|
||||||
|
tarot_position_name(position, gender), draw->reversed ? "reversed" : "",
|
||||||
|
tarot_card_image_file(draw->card), tarot_card_name(draw->card), tarot_card_name(draw->card),
|
||||||
|
reversed_marker(draw->reversed), tarot_position_description(position, gender),
|
||||||
|
tarot_card_meaning(draw->card, draw->reversed));
|
||||||
|
}
|
||||||
|
|
||||||
static void interp_print_html(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
|
static void interp_print_html(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
|
||||||
fprintf(out,
|
fprintf(out,
|
||||||
"<!doctype html>\n<html><head><meta charset=\"utf-8\">\n"
|
"<!doctype html>\n<html><head><meta charset=\"utf-8\">\n"
|
||||||
@@ -267,6 +334,11 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
|
|||||||
|
|
||||||
fprintf(out, "<h1>%s</h1>\n", ui("interp.heading", "Daily Interpretation"));
|
fprintf(out, "<h1>%s</h1>\n", ui("interp.heading", "Daily Interpretation"));
|
||||||
|
|
||||||
|
char date_buf[32];
|
||||||
|
format_date(reading->utc_moment, date_buf, sizeof date_buf);
|
||||||
|
fprintf(out, "<p class=\"reading-date\">%s %s</p>\n",
|
||||||
|
ui("interp.reading_date", "Reading for:"), date_buf);
|
||||||
|
|
||||||
fprintf(out, "<h2>%s</h2>\n<p>%s %s (%d/%d)",
|
fprintf(out, "<h2>%s</h2>\n<p>%s %s (%d/%d)",
|
||||||
ui("interp.heading_day_significance", "Day Significance"),
|
ui("interp.heading_day_significance", "Day Significance"),
|
||||||
ui("interp.day_significance", "Day significance:"), day_level_name(interp->day_level),
|
ui("interp.day_significance", "Day significance:"), day_level_name(interp->day_level),
|
||||||
@@ -277,48 +349,20 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
|
|||||||
}
|
}
|
||||||
fprintf(out, "</p>\n");
|
fprintf(out, "</p>\n");
|
||||||
|
|
||||||
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_transits", "Significant Transits"));
|
char *guidance = capture_guidance(interp, &reading->spread);
|
||||||
if (interp->top_item_count == 0) {
|
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_guidance", "Guidance"));
|
||||||
fprintf(out, "<p>%s</p>\n", ui("interp.no_notable_transits", "No notable transits today."));
|
|
||||||
} else {
|
|
||||||
fprintf(out, "<table>\n");
|
|
||||||
for (int i = 0; i < interp->top_item_count; i++) {
|
|
||||||
const SignificantItem *item = &interp->top_items[i];
|
|
||||||
const Aspect *a = &item->aspect;
|
|
||||||
char *text = capture_narrative(a, reading->transits.bodies[a->transiting_planet].house);
|
|
||||||
|
|
||||||
fprintf(out, "<tr><td>%d.</td><td>%s %s %s %s %s</td>"
|
fprintf(out, "<div class=\"spread attitude-outcome\">\n");
|
||||||
"<td>%s %.1f°, %s %.2f</td></tr>\n",
|
print_card_html(out, POSITION_ATTITUDE, &reading->spread.positions[POSITION_ATTITUDE], reading->gender);
|
||||||
i + 1, ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet),
|
print_card_html(out, POSITION_OUTCOME, &reading->spread.positions[POSITION_OUTCOME], reading->gender);
|
||||||
aspect_display_name(a->type), ui("ui.natal", "natal"),
|
|
||||||
body_display_name(a->natal_planet), ui("ui.orb", "orb"), a->orb,
|
|
||||||
ui("interp.score", "score"), item->score);
|
|
||||||
if (text[0] != '\0') fprintf(out, "<tr><td></td><td colspan=\"2\">%s</td></tr>\n", text);
|
|
||||||
free(text);
|
|
||||||
}
|
|
||||||
fprintf(out, "</table>\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
fprintf(out, "<h2>%s</h2>\n<div class=\"spread\">\n", ui("ui.celtic_cross", "Celtic Cross"));
|
|
||||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
|
||||||
const TarotDraw *draw = &reading->spread.positions[i];
|
|
||||||
fprintf(out,
|
|
||||||
"<div class=\"card\">\n"
|
|
||||||
" <div class=\"position\">%s</div>\n"
|
|
||||||
" <img class=\"%s\" src=\"img/%s\" alt=\"%s\">\n"
|
|
||||||
" <div class=\"name\">%s%s</div>\n"
|
|
||||||
" <div class=\"desc\">%s</div>\n"
|
|
||||||
" <div class=\"meaning\">%s</div>\n"
|
|
||||||
"</div>\n",
|
|
||||||
tarot_position_name((CelticCrossPosition)i), draw->reversed ? "reversed" : "",
|
|
||||||
tarot_card_image_file(draw->card), tarot_card_name(draw->card), tarot_card_name(draw->card),
|
|
||||||
reversed_marker(draw->reversed), tarot_position_description((CelticCrossPosition)i),
|
|
||||||
tarot_card_meaning(draw->card, draw->reversed));
|
|
||||||
}
|
|
||||||
fprintf(out, "</div>\n");
|
fprintf(out, "</div>\n");
|
||||||
|
|
||||||
char *guidance = capture_guidance(interp, &reading->spread);
|
fprintf(out, "<div class=\"guidance\">\n");
|
||||||
fprintf(out, "<h2>%s</h2>\n<div class=\"guidance\">\n", ui("interp.heading_guidance", "Guidance"));
|
if (interp->top_item_count > 0) {
|
||||||
|
fprintf(out, "<table>\n");
|
||||||
|
print_transit_item_html(out, reading, &interp->top_items[0], 0);
|
||||||
|
fprintf(out, "</table>\n");
|
||||||
|
}
|
||||||
const char *start = guidance;
|
const char *start = guidance;
|
||||||
for (const char *p = guidance; ; p++) {
|
for (const char *p = guidance; ; p++) {
|
||||||
if (*p == '\n' || *p == '\0') {
|
if (*p == '\n' || *p == '\0') {
|
||||||
@@ -330,6 +374,23 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
|
|||||||
free(guidance);
|
free(guidance);
|
||||||
fprintf(out, "</div>\n");
|
fprintf(out, "</div>\n");
|
||||||
|
|
||||||
|
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_transits", "Significant Transits"));
|
||||||
|
if (interp->top_item_count == 0) {
|
||||||
|
fprintf(out, "<p>%s</p>\n", ui("interp.no_notable_transits", "No notable transits today."));
|
||||||
|
} else {
|
||||||
|
fprintf(out, "<table>\n");
|
||||||
|
for (int i = 0; i < interp->top_item_count; i++) {
|
||||||
|
print_transit_item_html(out, reading, &interp->top_items[i], i + 1);
|
||||||
|
}
|
||||||
|
fprintf(out, "</table>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fprintf(out, "<h2>%s</h2>\n<div class=\"spread\">\n", ui("ui.celtic_cross", "Celtic Cross"));
|
||||||
|
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||||
|
print_card_html(out, (CelticCrossPosition)i, &reading->spread.positions[i], reading->gender);
|
||||||
|
}
|
||||||
|
fprintf(out, "</div>\n");
|
||||||
|
|
||||||
fprintf(out, "</body></html>\n");
|
fprintf(out, "</body></html>\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +417,10 @@ static void json_string(FILE *out, const char *s) {
|
|||||||
static void interp_print_json(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
|
static void interp_print_json(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
|
||||||
fprintf(out, "{\n");
|
fprintf(out, "{\n");
|
||||||
|
|
||||||
|
char date_buf[32];
|
||||||
|
format_date(reading->utc_moment, date_buf, sizeof date_buf);
|
||||||
|
fprintf(out, " \"date\": \"%s\",\n", date_buf);
|
||||||
|
|
||||||
fprintf(out, " \"day_significance\": {\n");
|
fprintf(out, " \"day_significance\": {\n");
|
||||||
fprintf(out, " \"level\": \"%s\",\n", k_day_level_slug[interp->day_level]);
|
fprintf(out, " \"level\": \"%s\",\n", k_day_level_slug[interp->day_level]);
|
||||||
fprintf(out, " \"rank\": %d,\n", interp->day_level + 1);
|
fprintf(out, " \"rank\": %d,\n", interp->day_level + 1);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
#define _DEFAULT_SOURCE /* for timegm, parsing "date" back into utc_moment */
|
||||||
|
|
||||||
#include "reading_io.h"
|
#include "reading_io.h"
|
||||||
#include "json.h"
|
#include "json.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
/* Mirrors astro.c's own k_body_slug/k_aspect_slug (its i18n lookup-key
|
/* Mirrors astro.c's own k_body_slug/k_aspect_slug (its i18n lookup-key
|
||||||
@@ -36,6 +39,12 @@ static const char *const k_position_slug[TAROT_SPREAD_SIZE] = {
|
|||||||
"near_future", "attitude", "environment", "hopes_and_fears", "outcome",
|
"near_future", "attitude", "environment", "hopes_and_fears", "outcome",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Mirrors tarot_data.c's own k_gender_slug, same duplication policy as
|
||||||
|
* k_card_slug/k_position_slug above (no reverse lookup exposed there). */
|
||||||
|
static const char *const k_gender_slug[3] = {
|
||||||
|
"unspecified", "male", "female",
|
||||||
|
};
|
||||||
|
|
||||||
static bool body_from_slug(const char *slug, Body *out) {
|
static bool body_from_slug(const char *slug, Body *out) {
|
||||||
for (int i = 0; i < NUM_BODIES; i++) {
|
for (int i = 0; i < NUM_BODIES; i++) {
|
||||||
if (strcmp(slug, k_body_slug[i]) == 0) {
|
if (strcmp(slug, k_body_slug[i]) == 0) {
|
||||||
@@ -101,6 +110,16 @@ static bool card_from_slug(const char *slug, TarotCard *out) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool gender_from_slug(const char *slug, Gender *out) {
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
if (strcmp(slug, k_gender_slug[i]) == 0) {
|
||||||
|
*out = (Gender)i;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static bool position_from_slug(const char *slug, CelticCrossPosition *out) {
|
static bool position_from_slug(const char *slug, CelticCrossPosition *out) {
|
||||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||||
if (strcmp(slug, k_position_slug[i]) == 0) {
|
if (strcmp(slug, k_position_slug[i]) == 0) {
|
||||||
@@ -133,6 +152,37 @@ static void parse_spread(const JsonValue *positions, CelticCrossSpread *out) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Parses the top-level "date" field ("YYYY-MM-DD", see reading_print_json
|
||||||
|
* in engine/src/reading.c) back into out->utc_moment, as midnight UTC of
|
||||||
|
* that date - reading_print_json only ever wrote the calendar date, not a
|
||||||
|
* time of day, so that's the only value that round-trips through
|
||||||
|
* gmtime() to the same date. Lenient like parse_bodies()/parse_spread():
|
||||||
|
* a missing or malformed "date" just leaves out->utc_moment at 0 (from
|
||||||
|
* reading_load_json's own memset), not a load failure - nothing here
|
||||||
|
* depends on it for scoring, only for display. */
|
||||||
|
static void parse_date(const JsonValue *date, DailyReading *out) {
|
||||||
|
const char *date_str = date ? json_as_string(date) : NULL;
|
||||||
|
if (!date_str) return;
|
||||||
|
|
||||||
|
struct tm tm_date = {0};
|
||||||
|
if (sscanf(date_str, "%d-%d-%d", &tm_date.tm_year, &tm_date.tm_mon, &tm_date.tm_mday) != 3) return;
|
||||||
|
tm_date.tm_year -= 1900;
|
||||||
|
tm_date.tm_mon -= 1;
|
||||||
|
out->utc_moment = timegm(&tm_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parses the top-level "gender" field (see reading_print_json in
|
||||||
|
* engine/src/reading.c) into out->gender. Lenient like parse_date(): a
|
||||||
|
* missing or unrecognized value just leaves out->gender at
|
||||||
|
* GENDER_UNSPECIFIED (0, from reading_load_json's own memset), not a
|
||||||
|
* load failure - it only affects Celtic Cross position text pronouns,
|
||||||
|
* nothing scoring depends on it. */
|
||||||
|
static void parse_gender(const JsonValue *gender, DailyReading *out) {
|
||||||
|
const char *gender_str = gender ? json_as_string(gender) : NULL;
|
||||||
|
if (!gender_str) return;
|
||||||
|
gender_from_slug(gender_str, &out->gender);
|
||||||
|
}
|
||||||
|
|
||||||
bool reading_load_json(const char *text, size_t length, DailyReading *out) {
|
bool reading_load_json(const char *text, size_t length, DailyReading *out) {
|
||||||
memset(out, 0, sizeof(*out));
|
memset(out, 0, sizeof(*out));
|
||||||
|
|
||||||
@@ -174,6 +224,9 @@ bool reading_load_json(const char *text, size_t length, DailyReading *out) {
|
|||||||
}
|
}
|
||||||
out->transits.aspect_count = filled;
|
out->transits.aspect_count = filled;
|
||||||
|
|
||||||
|
parse_date(json_object_get(root, "date"), out);
|
||||||
|
parse_gender(json_object_get(root, "gender"), out);
|
||||||
|
|
||||||
const JsonValue *natal = json_object_get(root, "natal");
|
const JsonValue *natal = json_object_get(root, "natal");
|
||||||
parse_bodies(natal ? json_object_get(natal, "bodies") : NULL, out->natal.bodies);
|
parse_bodies(natal ? json_object_get(natal, "bodies") : NULL, out->natal.bodies);
|
||||||
parse_bodies(json_object_get(transits, "bodies"), out->transits.bodies);
|
parse_bodies(json_object_get(transits, "bodies"), out->transits.bodies);
|
||||||
|
|||||||
@@ -14,10 +14,15 @@
|
|||||||
* around each significant event, and for guidance_print()'s tarot
|
* around each significant event, and for guidance_print()'s tarot
|
||||||
* framing: out->transits.aspects[]/aspect_count (used for scoring - see
|
* framing: out->transits.aspects[]/aspect_count (used for scoring - see
|
||||||
* significance.c), out->natal.bodies[]/out->transits.bodies[] (sign +
|
* significance.c), out->natal.bodies[]/out->transits.bodies[] (sign +
|
||||||
* house per body, used only for display), and out->spread.positions[]
|
* house per body, used only for display), out->spread.positions[]
|
||||||
* (card + reversed per Celtic Cross position, used only by
|
* (card + reversed per Celtic Cross position, used only by
|
||||||
* guidance.c). out->natal.ascendant_longitude/houses[] are left zeroed -
|
* guidance.c), out->utc_moment (the top-level "date" field, parsed
|
||||||
* nothing reads them yet.
|
* back to midnight UTC of that date - used only for display, e.g. main.c
|
||||||
|
* printing which day a reading is for), and out->gender (the top-level
|
||||||
|
* "gender" field - used only for the Celtic Cross position text's
|
||||||
|
* pronouns, e.g. tarot_position_name()/tarot_position_description()).
|
||||||
|
* out->natal.ascendant_longitude/houses[] are left zeroed - nothing
|
||||||
|
* reads them yet.
|
||||||
*
|
*
|
||||||
* Returns false on malformed JSON, or if "transits"."aspects" isn't
|
* Returns false on malformed JSON, or if "transits"."aspects" isn't
|
||||||
* present as an array (an empty array is fine - that's a real "no
|
* present as an array (an empty array is fine - that's a real "no
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include <assert.h>
|
#include <assert.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
#include "../src/json.h"
|
#include "../src/json.h"
|
||||||
#include "../src/reading_io.h"
|
#include "../src/reading_io.h"
|
||||||
@@ -48,6 +49,8 @@ static void test_json_parse_rejects_malformed(void) {
|
|||||||
* absent entirely, to prove partial spread data doesn't fail the load. */
|
* absent entirely, to prove partial spread data doesn't fail the load. */
|
||||||
static const char *k_fixture =
|
static const char *k_fixture =
|
||||||
"{"
|
"{"
|
||||||
|
" \"date\": \"2026-07-16\","
|
||||||
|
" \"gender\": \"female\","
|
||||||
" \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\", \"house\": 3}], \"houses\": []},"
|
" \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\", \"house\": 3}], \"houses\": []},"
|
||||||
" \"transits\": {"
|
" \"transits\": {"
|
||||||
" \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\", \"house\": 5}],"
|
" \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\", \"house\": 5}],"
|
||||||
@@ -80,6 +83,62 @@ static void test_reading_load_json_extracts_aspects(void) {
|
|||||||
printf("PASS test_reading_load_json_extracts_aspects\n");
|
printf("PASS test_reading_load_json_extracts_aspects\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_reading_load_json_extracts_date(void) {
|
||||||
|
DailyReading reading;
|
||||||
|
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
|
||||||
|
|
||||||
|
assert(ok);
|
||||||
|
struct tm *utc = gmtime(&reading.utc_moment);
|
||||||
|
assert(utc->tm_year + 1900 == 2026);
|
||||||
|
assert(utc->tm_mon + 1 == 7);
|
||||||
|
assert(utc->tm_mday == 16);
|
||||||
|
|
||||||
|
printf("PASS test_reading_load_json_extracts_date\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_reading_load_json_missing_date_defaults_to_zero(void) {
|
||||||
|
const char *text = "{\"transits\": {\"aspects\": []}}";
|
||||||
|
DailyReading reading;
|
||||||
|
bool ok = reading_load_json(text, strlen(text), &reading);
|
||||||
|
|
||||||
|
assert(ok);
|
||||||
|
assert(reading.utc_moment == 0);
|
||||||
|
|
||||||
|
printf("PASS test_reading_load_json_missing_date_defaults_to_zero\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_reading_load_json_extracts_gender(void) {
|
||||||
|
DailyReading reading;
|
||||||
|
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
|
||||||
|
|
||||||
|
assert(ok);
|
||||||
|
assert(reading.gender == GENDER_FEMALE);
|
||||||
|
|
||||||
|
printf("PASS test_reading_load_json_extracts_gender\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_reading_load_json_missing_gender_defaults_to_unspecified(void) {
|
||||||
|
const char *text = "{\"transits\": {\"aspects\": []}}";
|
||||||
|
DailyReading reading;
|
||||||
|
bool ok = reading_load_json(text, strlen(text), &reading);
|
||||||
|
|
||||||
|
assert(ok);
|
||||||
|
assert(reading.gender == GENDER_UNSPECIFIED);
|
||||||
|
|
||||||
|
printf("PASS test_reading_load_json_missing_gender_defaults_to_unspecified\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_reading_load_json_unrecognized_gender_defaults_to_unspecified(void) {
|
||||||
|
const char *text = "{\"gender\": \"xenu\", \"transits\": {\"aspects\": []}}";
|
||||||
|
DailyReading reading;
|
||||||
|
bool ok = reading_load_json(text, strlen(text), &reading);
|
||||||
|
|
||||||
|
assert(ok);
|
||||||
|
assert(reading.gender == GENDER_UNSPECIFIED);
|
||||||
|
|
||||||
|
printf("PASS test_reading_load_json_unrecognized_gender_defaults_to_unspecified\n");
|
||||||
|
}
|
||||||
|
|
||||||
static void test_reading_load_json_extracts_body_sign_and_house(void) {
|
static void test_reading_load_json_extracts_body_sign_and_house(void) {
|
||||||
DailyReading reading;
|
DailyReading reading;
|
||||||
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
|
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
|
||||||
@@ -178,6 +237,11 @@ int main(void) {
|
|||||||
test_json_parse_basic_shapes();
|
test_json_parse_basic_shapes();
|
||||||
test_json_parse_rejects_malformed();
|
test_json_parse_rejects_malformed();
|
||||||
test_reading_load_json_extracts_aspects();
|
test_reading_load_json_extracts_aspects();
|
||||||
|
test_reading_load_json_extracts_date();
|
||||||
|
test_reading_load_json_missing_date_defaults_to_zero();
|
||||||
|
test_reading_load_json_extracts_gender();
|
||||||
|
test_reading_load_json_missing_gender_defaults_to_unspecified();
|
||||||
|
test_reading_load_json_unrecognized_gender_defaults_to_unspecified();
|
||||||
test_reading_load_json_extracts_body_sign_and_house();
|
test_reading_load_json_extracts_body_sign_and_house();
|
||||||
test_reading_load_json_extracts_spread_positions();
|
test_reading_load_json_extracts_spread_positions();
|
||||||
test_reading_load_json_missing_spread_is_not_fatal();
|
test_reading_load_json_missing_spread_is_not_fatal();
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Copy to registry.env (gitignored, never commit the real values) and
|
||||||
|
# fill in your cr.ladkau.de credentials. Used by build-image.sh,
|
||||||
|
# run-image.sh, and upload-image.sh.
|
||||||
|
REGISTRY=cr.ladkau.de
|
||||||
|
REGISTRY_IMAGE=cr.ladkau.de/deck-in-a-dash/builder
|
||||||
|
REGISTRY_USER=
|
||||||
|
REGISTRY_PASSWORD=
|
||||||
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.
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs `make test package watchapp android` inside the local build-image
|
||||||
|
# container, mirroring what the Gitea Actions build workflow does -
|
||||||
|
# useful for testing build-image changes locally before pushing
|
||||||
|
# anything to cr.ladkau.de.
|
||||||
|
#
|
||||||
|
# Usage: ./run-image.sh [VERSION]
|
||||||
|
# VERSION is passed through to `make` (see the Makefile's `package`/
|
||||||
|
# `watchapp`/`android` targets); omit it for their own git-tag-based
|
||||||
|
# default.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
ROOT="$(pwd)"
|
||||||
|
|
||||||
|
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 \
|
||||||
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
|
[ -f "$ROOT/registry.env" ] \
|
||||||
|
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$ROOT/registry.env"
|
||||||
|
|
||||||
|
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
|
||||||
|
|
||||||
|
IMAGE_TAG="$(<"$ROOT/build-image/VERSION")"
|
||||||
|
[ -n "$IMAGE_TAG" ] || fail "build-image/VERSION is empty"
|
||||||
|
IMAGE="$REGISTRY_IMAGE:$IMAGE_TAG"
|
||||||
|
|
||||||
|
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|
||||||
|
|| fail "$IMAGE not found locally — run ./build-image.sh first"
|
||||||
|
|
||||||
|
VERSION="${1:-${VERSION:-}}"
|
||||||
|
|
||||||
|
# CI always starts from a fresh checkout, but this script bind-mounts the
|
||||||
|
# live host repo — so build output left over from a previous local run
|
||||||
|
# (build/, dist/, watch/build, the generated watch resources below, and
|
||||||
|
# the Android app's own build/generated-resource dirs) would make this
|
||||||
|
# run skip work a real fresh checkout wouldn't, silently hiding bugs that
|
||||||
|
# only show up in CI. Wipe them first so every run exercises a true
|
||||||
|
# from-scratch build, same as CI.
|
||||||
|
echo "== Cleaning generated build artifacts for a fresh build =="
|
||||||
|
rm -rf \
|
||||||
|
"$ROOT/build" "$ROOT/dist" "$ROOT/watch/build" \
|
||||||
|
"$ROOT/watch/resources/img" "$ROOT/watch/resources/app_icon.png" \
|
||||||
|
"$ROOT/watch/src/c/i18n_tables.auto.c" \
|
||||||
|
"$ROOT/android/build" "$ROOT/android/app/build" "$ROOT/android/app/.cxx" \
|
||||||
|
"$ROOT/android/app/src/main/assets/i18n" "$ROOT/android/app/src/main/res/drawable-nodpi" \
|
||||||
|
"$ROOT/android/app/src/main/res"/mipmap-mdpi "$ROOT/android/app/src/main/res"/mipmap-hdpi \
|
||||||
|
"$ROOT/android/app/src/main/res"/mipmap-xhdpi "$ROOT/android/app/src/main/res"/mipmap-xxhdpi \
|
||||||
|
"$ROOT/android/app/src/main/res"/mipmap-xxxhdpi
|
||||||
|
|
||||||
|
echo "== Running make test package watchapp android inside $IMAGE =="
|
||||||
|
# The container runs as root (needed for the toolchain baked into the
|
||||||
|
# image), so anything it writes into this bind mount — dist/, build/,
|
||||||
|
# watch/build, android/.gradle, etc. — would otherwise come back owned
|
||||||
|
# by root, leaving the host repo unusable without sudo. Chown everything
|
||||||
|
# back to the host user on exit, whether the build succeeds or fails.
|
||||||
|
docker run --rm \
|
||||||
|
-v "$ROOT:/workspace" \
|
||||||
|
-w /workspace \
|
||||||
|
-e VERSION="$VERSION" \
|
||||||
|
-e HOST_UID="$(id -u)" \
|
||||||
|
-e HOST_GID="$(id -g)" \
|
||||||
|
"$IMAGE" \
|
||||||
|
bash -c '
|
||||||
|
git config --global --add safe.directory /workspace
|
||||||
|
trap "chown -R \"$HOST_UID:$HOST_GID\" /workspace" EXIT
|
||||||
|
make test package watchapp android
|
||||||
|
'
|
||||||
|
|
||||||
|
echo "== Done — artifacts in dist/ =="
|
||||||
|
ls -la "$ROOT/dist"
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pushes the build environment image — already built locally with
|
||||||
|
# ./build-image.sh, and ideally verified with ./run-image.sh — to
|
||||||
|
# cr.ladkau.de. The Gitea Actions build workflow pulls this image to run
|
||||||
|
# `make test package watchapp`.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
ROOT="$(pwd)"
|
||||||
|
|
||||||
|
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 \
|
||||||
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
|
[ -f "$ROOT/registry.env" ] \
|
||||||
|
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$ROOT/registry.env"
|
||||||
|
|
||||||
|
: "${REGISTRY:?registry.env must set REGISTRY}"
|
||||||
|
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
|
||||||
|
: "${REGISTRY_USER:?registry.env must set REGISTRY_USER}"
|
||||||
|
: "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}"
|
||||||
|
|
||||||
|
VERSION="$(<"$ROOT/build-image/VERSION")"
|
||||||
|
[ -n "$VERSION" ] || fail "build-image/VERSION is empty"
|
||||||
|
|
||||||
|
docker image inspect "$REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \
|
||||||
|
|| fail "$REGISTRY_IMAGE:$VERSION not found locally — run ./build-image.sh first"
|
||||||
|
|
||||||
|
echo "== Logging in to $REGISTRY =="
|
||||||
|
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
|
||||||
|
|
||||||
|
echo "== Pushing $REGISTRY_IMAGE:$VERSION and :latest =="
|
||||||
|
docker push "$REGISTRY_IMAGE:$VERSION"
|
||||||
|
docker push "$REGISTRY_IMAGE:latest"
|
||||||
|
|
||||||
|
echo "== Done =="
|
||||||
|
echo "Image: $REGISTRY_IMAGE:$VERSION"
|
||||||
+123
-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",
|
||||||
@@ -30,6 +35,7 @@
|
|||||||
"BIRTH_UTC_OFFSET_MINUTES",
|
"BIRTH_UTC_OFFSET_MINUTES",
|
||||||
"BIRTH_LAT_MICRODEG",
|
"BIRTH_LAT_MICRODEG",
|
||||||
"BIRTH_LON_MICRODEG",
|
"BIRTH_LON_MICRODEG",
|
||||||
|
"GENDER",
|
||||||
"LANG",
|
"LANG",
|
||||||
"NOTIFY_ENABLED",
|
"NOTIFY_ENABLED",
|
||||||
"NOTIFY_HOUR",
|
"NOTIFY_HOUR",
|
||||||
@@ -37,28 +43,122 @@
|
|||||||
],
|
],
|
||||||
"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": "IMAGE_APP_ICON",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_EMPRESS", "file": "img/empress.png" },
|
"file": "app_icon.png",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_EMPEROR", "file": "img/emperor.png" },
|
"menuIcon": true
|
||||||
{ "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",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_STRENGTH", "file": "img/strength.png" },
|
"name": "IMG_CARD_FOOL",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_HERMIT", "file": "img/hermit.png" },
|
"file": "img/fool.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",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_DEATH", "file": "img/death.png" },
|
"name": "IMG_CARD_MAGICIAN",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_TEMPERANCE", "file": "img/temperance.png" },
|
"file": "img/magician.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",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_MOON", "file": "img/moon.png" },
|
"name": "IMG_CARD_HIGH_PRIESTESS",
|
||||||
{ "type": "bitmap", "name": "IMG_CARD_SUN", "file": "img/sun.png" },
|
"file": "img/high_priestess.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_EMPRESS",
|
||||||
|
"file": "img/empress.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_EMPEROR",
|
||||||
|
"file": "img/emperor.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_HIEROPHANT",
|
||||||
|
"file": "img/hierophant.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_LOVERS",
|
||||||
|
"file": "img/lovers.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_CHARIOT",
|
||||||
|
"file": "img/chariot.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_STRENGTH",
|
||||||
|
"file": "img/strength.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_HERMIT",
|
||||||
|
"file": "img/hermit.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_WHEEL_OF_FORTUNE",
|
||||||
|
"file": "img/wheel_of_fortune.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_JUSTICE",
|
||||||
|
"file": "img/justice.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_HANGED_MAN",
|
||||||
|
"file": "img/hanged_man.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_DEATH",
|
||||||
|
"file": "img/death.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_TEMPERANCE",
|
||||||
|
"file": "img/temperance.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_DEVIL",
|
||||||
|
"file": "img/devil.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_TOWER",
|
||||||
|
"file": "img/tower.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_STAR",
|
||||||
|
"file": "img/star.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_MOON",
|
||||||
|
"file": "img/moon.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_SUN",
|
||||||
|
"file": "img/sun.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_JUDGEMENT",
|
||||||
|
"file": "img/judgement.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_WORLD",
|
||||||
|
"file": "img/world.png"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Downsizes res/app_icon_50x50_64_color.png into watch/resources/app_icon.png.
|
||||||
|
|
||||||
|
Pebble's own build tooling (process_sdk_resources.py) hard-fails any
|
||||||
|
"menuIcon": true resource larger than 25x25px - the fixed size Pebble OS
|
||||||
|
renders app icons at in the watch's own app launcher list (and, synced
|
||||||
|
from the watch, in the mobile app's installed-apps list too). The source
|
||||||
|
art in res/ is 50x50 (square, so it downsamples to exactly 25x25 with no
|
||||||
|
letterboxing) - the color variant is used, per the same "prefer color"
|
||||||
|
choice as the rest of this app's card art, over the companion
|
||||||
|
*_gray.png fallback also present in res/.
|
||||||
|
|
||||||
|
Generated output, like i18n_tables.auto.c and resources/img/*.png - not
|
||||||
|
committed (see .gitignore), regenerate by running this script directly
|
||||||
|
or via `pebble build` (wired into wscript alongside gen_card_images.py).
|
||||||
|
Requires Pillow (`pip install Pillow`), not part of this repo's own
|
||||||
|
desktop build/test toolchain.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
SRC_PATH = os.path.normpath(
|
||||||
|
os.path.join(SCRIPT_DIR, "..", "..", "res", "app_icon_50x50_64_color.png")
|
||||||
|
)
|
||||||
|
OUT_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "resources", "app_icon.png"))
|
||||||
|
|
||||||
|
MAX_ICON_SIZE = 25 # Pebble's own hard cap for "menuIcon": true resources.
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if os.path.exists(OUT_PATH) and os.path.getmtime(OUT_PATH) >= os.path.getmtime(SRC_PATH):
|
||||||
|
print(f"{OUT_PATH} already up to date", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
|
||||||
|
with Image.open(SRC_PATH) as img:
|
||||||
|
ratio = MAX_ICON_SIZE / max(img.width, img.height)
|
||||||
|
size = (round(img.width * ratio), round(img.height * ratio))
|
||||||
|
resized = img.convert("RGBA").resize(size, Image.LANCZOS)
|
||||||
|
resized.save(OUT_PATH, "PNG", optimize=True)
|
||||||
|
|
||||||
|
print(f"wrote {OUT_PATH} ({size[0]}x{size[1]})", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ static void inbox_received_handler(DictionaryIterator *iter, void *context) {
|
|||||||
cfg.birth.longitude = v / 1000000.0;
|
cfg.birth.longitude = v / 1000000.0;
|
||||||
got_birth = true;
|
got_birth = true;
|
||||||
}
|
}
|
||||||
|
if (read_int(iter, MESSAGE_KEY_GENDER, &v)) cfg.gender = (Gender)v;
|
||||||
|
|
||||||
Tuple *lang_tuple = dict_find(iter, MESSAGE_KEY_LANG);
|
Tuple *lang_tuple = dict_find(iter, MESSAGE_KEY_LANG);
|
||||||
if (lang_tuple) {
|
if (lang_tuple) {
|
||||||
@@ -60,6 +61,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ enum {
|
|||||||
PKEY_BIRTH_UTC_OFFSET_MINUTES,
|
PKEY_BIRTH_UTC_OFFSET_MINUTES,
|
||||||
PKEY_BIRTH_LAT_MICRODEG,
|
PKEY_BIRTH_LAT_MICRODEG,
|
||||||
PKEY_BIRTH_LON_MICRODEG,
|
PKEY_BIRTH_LON_MICRODEG,
|
||||||
|
PKEY_GENDER,
|
||||||
PKEY_LANG,
|
PKEY_LANG,
|
||||||
PKEY_NOTIFY_ENABLED,
|
PKEY_NOTIFY_ENABLED,
|
||||||
PKEY_NOTIFY_HOUR,
|
PKEY_NOTIFY_HOUR,
|
||||||
@@ -40,6 +41,7 @@ void config_load(WatchConfig *out) {
|
|||||||
out->birth.utc_offset_hours = (double)(int32_t)persist_read_int(PKEY_BIRTH_UTC_OFFSET_MINUTES) / 60.0;
|
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.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;
|
out->birth.longitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LON_MICRODEG) / 1000000.0;
|
||||||
|
out->gender = (Gender)persist_read_int(PKEY_GENDER); /* defaults to 0 = GENDER_UNSPECIFIED */
|
||||||
|
|
||||||
if (persist_read_string(PKEY_LANG, out->lang, sizeof out->lang) <= 0) {
|
if (persist_read_string(PKEY_LANG, out->lang, sizeof out->lang) <= 0) {
|
||||||
strcpy(out->lang, "en");
|
strcpy(out->lang, "en");
|
||||||
@@ -61,6 +63,7 @@ void config_save(const WatchConfig *cfg) {
|
|||||||
persist_write_int(PKEY_BIRTH_UTC_OFFSET_MINUTES, (int32_t)(cfg->birth.utc_offset_hours * 60.0));
|
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_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_int(PKEY_BIRTH_LON_MICRODEG, (int32_t)(cfg->birth.longitude * 1000000.0));
|
||||||
|
persist_write_int(PKEY_GENDER, (int32_t)cfg->gender);
|
||||||
|
|
||||||
persist_write_string(PKEY_LANG, cfg->lang);
|
persist_write_string(PKEY_LANG, cfg->lang);
|
||||||
|
|
||||||
@@ -68,3 +71,20 @@ 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 gender=%d lang=%s",
|
||||||
|
(long)(cfg->birth.latitude * 1000000), (long)(cfg->birth.longitude * 1000000),
|
||||||
|
(int)cfg->gender, cfg->lang);
|
||||||
|
APP_LOG(APP_LOG_LEVEL_INFO, "config: notify_enabled=%d at %02d:%02d",
|
||||||
|
cfg->notify_enabled, cfg->notify_hour, cfg->notify_minute);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <pebble.h>
|
#include <pebble.h>
|
||||||
|
|
||||||
#include "../../../engine/src/astro.h"
|
#include "../../../engine/src/astro.h"
|
||||||
|
#include "../../../engine/src/tarot.h" /* Gender */
|
||||||
|
|
||||||
/* Everything the watch needs that isn't computed fresh each day: birth
|
/* Everything the watch needs that isn't computed fresh each day: birth
|
||||||
* data (the same fields as dist/user.properties), display language, and
|
* data (the same fields as dist/user.properties), display language, and
|
||||||
@@ -13,6 +14,9 @@
|
|||||||
typedef struct {
|
typedef struct {
|
||||||
bool configured; /* false until the config page has been submitted once */
|
bool configured; /* false until the config page has been submitted once */
|
||||||
BirthData birth;
|
BirthData birth;
|
||||||
|
Gender gender; /* who the reading is for - only affects Celtic Cross
|
||||||
|
* position text pronouns, see engine/src/tarot.h.
|
||||||
|
* Defaults to GENDER_UNSPECIFIED (0). */
|
||||||
char lang[8]; /* e.g. "en", "de" - matches engine/i18n/<lang>.lang's own code */
|
char lang[8]; /* e.g. "en", "de" - matches engine/i18n/<lang>.lang's own code */
|
||||||
bool notify_enabled;
|
bool notify_enabled;
|
||||||
int notify_hour; /* 0-23, the watch's own local wall-clock time (i.e.
|
int notify_hour; /* 0-23, the watch's own local wall-clock time (i.e.
|
||||||
@@ -28,4 +32,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
|
||||||
|
|||||||
+32
-7
@@ -4,8 +4,9 @@
|
|||||||
#include "app_message.h"
|
#include "app_message.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "i18n_tables.h"
|
#include "i18n_tables.h"
|
||||||
|
#include "notify.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,27 +25,51 @@ static void on_config_updated(const WatchConfig *cfg) {
|
|||||||
|
|
||||||
if (!s_config.configured) return;
|
if (!s_config.configured) return;
|
||||||
|
|
||||||
reading_state_recompute(&s_config.birth);
|
config_log(&s_config); /* the values this newly-recomputed reading is based on */
|
||||||
|
reading_state_recompute(&s_config.birth, s_config.gender);
|
||||||
|
notify_reschedule(&s_config); /* settings may have changed notify_enabled/hour/minute */
|
||||||
if (was_configured) {
|
if (was_configured) {
|
||||||
menu_window_reload();
|
report_window_reload();
|
||||||
} else {
|
} else {
|
||||||
menu_window_push();
|
report_window_push();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fires only if the scheduled wakeup goes off while this app is already
|
||||||
|
* open (notify_was_wakeup_launch() below covers the far more common
|
||||||
|
* case: the wakeup launching the app fresh). Refreshes the same way a
|
||||||
|
* new day's config submission would, since the report window is already
|
||||||
|
* on screen and the reading may now be for a new day. */
|
||||||
|
static void on_wakeup(WakeupId wakeup_id, int32_t cookie) {
|
||||||
|
(void)wakeup_id;
|
||||||
|
(void)cookie;
|
||||||
|
vibes_double_pulse();
|
||||||
|
if (!s_config.configured) return;
|
||||||
|
reading_state_recompute(&s_config.birth, s_config.gender);
|
||||||
|
report_window_reload();
|
||||||
|
notify_reschedule(&s_config); /* wakeup_schedule() is one-shot - re-arm for tomorrow */
|
||||||
|
}
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
config_load(&s_config);
|
config_load(&s_config);
|
||||||
watch_i18n_load(s_config.lang);
|
watch_i18n_load(s_config.lang);
|
||||||
app_message_init(on_config_updated);
|
app_message_init(on_config_updated);
|
||||||
|
notify_subscribe(on_wakeup);
|
||||||
|
|
||||||
|
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, s_config.gender);
|
||||||
menu_window_push();
|
report_window_push();
|
||||||
|
notify_reschedule(&s_config); /* wakeup_schedule() is one-shot - re-arm on every launch */
|
||||||
|
if (notify_was_wakeup_launch()) {
|
||||||
|
vibes_double_pulse();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
show_setup_required();
|
show_setup_required();
|
||||||
}
|
}
|
||||||
|
|
||||||
app_event_loop();
|
app_event_loop();
|
||||||
|
|
||||||
menu_window_deinit();
|
report_window_deinit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#include "notify.h"
|
||||||
|
|
||||||
|
/* Only one kind of wakeup event exists in this app, so the cookie value
|
||||||
|
* itself carries no information - it exists only because
|
||||||
|
* wakeup_schedule()/wakeup_get_launch_event() require one. */
|
||||||
|
#define NOTIFY_WAKEUP_COOKIE 0
|
||||||
|
|
||||||
|
void notify_reschedule(const WatchConfig *cfg) {
|
||||||
|
wakeup_cancel_all();
|
||||||
|
if (!cfg->notify_enabled) return;
|
||||||
|
|
||||||
|
time_t now = time(NULL);
|
||||||
|
struct tm target_tm = *localtime(&now);
|
||||||
|
target_tm.tm_hour = cfg->notify_hour;
|
||||||
|
target_tm.tm_min = cfg->notify_minute;
|
||||||
|
target_tm.tm_sec = 0;
|
||||||
|
|
||||||
|
time_t target = mktime(&target_tm);
|
||||||
|
if (target <= now) target += SECONDS_PER_DAY; /* today's time already passed */
|
||||||
|
|
||||||
|
WakeupId id = wakeup_schedule(target, NOTIFY_WAKEUP_COOKIE, true);
|
||||||
|
if (id < 0) {
|
||||||
|
APP_LOG(APP_LOG_LEVEL_ERROR, "notify: wakeup_schedule failed: %d", (int)id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool notify_was_wakeup_launch(void) {
|
||||||
|
return launch_reason() == APP_LAUNCH_WAKEUP;
|
||||||
|
}
|
||||||
|
|
||||||
|
void notify_subscribe(WakeupHandler handler) {
|
||||||
|
wakeup_service_subscribe(handler);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#ifndef WATCH_NOTIFY_H
|
||||||
|
#define WATCH_NOTIFY_H
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
/* (Re)schedules the daily notification wakeup from `cfg`'s notify_*
|
||||||
|
* fields: cancels any wakeup this app previously scheduled, then - if
|
||||||
|
* cfg->notify_enabled - schedules a new one-shot wakeup_schedule() for
|
||||||
|
* the next occurrence of notify_hour:notify_minute in the watch's own
|
||||||
|
* local time (today if that time hasn't passed yet, tomorrow otherwise).
|
||||||
|
* If !cfg->notify_enabled, this only cancels - no new wakeup is
|
||||||
|
* scheduled.
|
||||||
|
*
|
||||||
|
* Safe to call any time, and needs to be: once per app launch (Pebble's
|
||||||
|
* wakeup_schedule() is one-shot - each firing consumes itself, so the
|
||||||
|
* next day's has to be scheduled again after it fires or after a wakeup
|
||||||
|
* that arrives while the app happens to already be open - see
|
||||||
|
* notify_subscribe()) and on every config submission (settings may have
|
||||||
|
* changed notify_enabled/notify_hour/notify_minute). */
|
||||||
|
void notify_reschedule(const WatchConfig *cfg);
|
||||||
|
|
||||||
|
/* True if this app launch was triggered by the daily notification wakeup
|
||||||
|
* firing rather than the user/phone opening it normally - main.c uses
|
||||||
|
* this to vibrate on launch, since the report window it pushes either
|
||||||
|
* way already leads with day significance and the top transit (the
|
||||||
|
* "quick digest" this notification promises), so no separate screen is
|
||||||
|
* needed for it. */
|
||||||
|
bool notify_was_wakeup_launch(void);
|
||||||
|
|
||||||
|
/* Subscribes `handler` to fire if the scheduled wakeup goes off while
|
||||||
|
* this app is already running in the foreground - the only case
|
||||||
|
* notify_was_wakeup_launch() can't see, since no fresh launch happens
|
||||||
|
* then (see wakeup_service_subscribe()'s own doc comment in pebble.h).
|
||||||
|
* Call once, early in main(). */
|
||||||
|
void notify_subscribe(WakeupHandler handler);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
static DailyReading s_reading;
|
static DailyReading s_reading;
|
||||||
static DailyInterpretation s_interp;
|
static DailyInterpretation s_interp;
|
||||||
|
|
||||||
void reading_state_recompute(const BirthData *birth) {
|
void reading_state_recompute(const BirthData *birth, Gender gender) {
|
||||||
time_t now = time(NULL);
|
time_t now = time(NULL);
|
||||||
/* Plain gmtime() (not the POSIX-only gmtime_r()), used immediately -
|
/* Plain gmtime() (not the POSIX-only gmtime_r()), used immediately -
|
||||||
* same justification as astro.c's own time_from_unix(). */
|
* same justification as astro.c's own time_from_unix(). */
|
||||||
@@ -14,7 +14,7 @@ void reading_state_recompute(const BirthData *birth) {
|
|||||||
char seed[16];
|
char seed[16];
|
||||||
snprintf(seed, sizeof seed, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
|
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);
|
reading_generate(seed, birth, gender, now, &s_reading);
|
||||||
interpret_daily_reading(&s_reading, &s_interp);
|
interpret_daily_reading(&s_reading, &s_interp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
#include "../../../interpreter/src/significance.h"
|
#include "../../../interpreter/src/significance.h"
|
||||||
|
|
||||||
/* Recomputes today's reading (astro + tarot + interpretation) from
|
/* Recomputes today's reading (astro + tarot + interpretation) from
|
||||||
* `birth`, using the watch's current clock: today's UTC date as the
|
* `birth`/`gender`, using the watch's current clock: today's UTC date as
|
||||||
* tarot seed (same convention as dist/run-engine.sh - see CLAUDE.md's
|
* 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
|
* "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
|
* to call again later (e.g. the day rolled over while the app stayed
|
||||||
* open) - overwrites the previous result in place. */
|
* open) - overwrites the previous result in place. */
|
||||||
void reading_state_recompute(const BirthData *birth);
|
void reading_state_recompute(const BirthData *birth, Gender gender);
|
||||||
|
|
||||||
/* Valid only after at least one reading_state_recompute() call. */
|
/* Valid only after at least one reading_state_recompute() call. */
|
||||||
const DailyReading *reading_state_get_reading(void);
|
const DailyReading *reading_state_get_reading(void);
|
||||||
|
|||||||
@@ -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,331 @@
|
|||||||
|
#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_reading_date[48];
|
||||||
|
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];
|
||||||
|
|
||||||
|
/* Attitude (0) and Outcome (1) preview slots, drawn at half screen
|
||||||
|
* width each so the two sit side by side - the BitmapLayer's own bounds
|
||||||
|
* (narrower than IMAGE_WIDTH on 144px-wide platforms) clip the centered
|
||||||
|
* image automatically, same as GAlignCenter already does for the
|
||||||
|
* full-width slots above, no separate scaling logic needed. */
|
||||||
|
#define REPEAT_SLOT_COUNT 2
|
||||||
|
static ImageSlot s_repeat_slots[REPEAT_SLOT_COUNT];
|
||||||
|
|
||||||
|
#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_at(int16_t x, int16_t width, int16_t y, const char *text, GFont font) {
|
||||||
|
TextLayer *tl = text_layer_create(GRect(x, 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 int16_t add_text(int16_t width, int16_t y, const char *text, GFont font) {
|
||||||
|
return add_text_at(0, width, y, text, font);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < REPEAT_SLOT_COUNT; i++) {
|
||||||
|
if (s_repeat_slots[i].bitmap) {
|
||||||
|
gbitmap_destroy(s_repeat_slots[i].bitmap);
|
||||||
|
s_repeat_slots[i].bitmap = NULL;
|
||||||
|
}
|
||||||
|
if (s_repeat_slots[i].bitmap_layer) {
|
||||||
|
bitmap_layer_destroy(s_repeat_slots[i].bitmap_layer);
|
||||||
|
s_repeat_slots[i].bitmap_layer = NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void update_slot_visibility(ImageSlot *slot, int16_t visible_top, int16_t visible_bottom) {
|
||||||
|
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 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++) {
|
||||||
|
update_slot_visibility(&s_slots[i], visible_top, visible_bottom);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < REPEAT_SLOT_COUNT; i++) {
|
||||||
|
update_slot_visibility(&s_repeat_slots[i], visible_top, visible_bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/* The day this reading is for (reading->utc_moment, set by
|
||||||
|
* reading_generate() - see reading_state_recompute()), not necessarily
|
||||||
|
* "today" if the report is viewed right at a UTC-midnight boundary
|
||||||
|
* before the next recompute. Plain gmtime(), same justification as
|
||||||
|
* reading_state.c's own use of it. */
|
||||||
|
struct tm *utc = gmtime(&reading->utc_moment);
|
||||||
|
snprintf(s_reading_date, sizeof s_reading_date, "%s %04d-%02d-%02d",
|
||||||
|
i18n_get("interp.reading_date", "Reading for:"), utc->tm_year + 1900,
|
||||||
|
utc->tm_mon + 1, utc->tm_mday);
|
||||||
|
y = add_text(width, y, s_reading_date, s_font_body);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
/* Build every transit's title + narrative up front (used both for the
|
||||||
|
* repeated top-item line below and the full list further down), so
|
||||||
|
* neither buffer is computed twice for item 0. */
|
||||||
|
if (interp->top_item_count > 0) {
|
||||||
|
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));
|
||||||
|
narrative_write(s_narratives[i], sizeof s_narratives[i], "", a,
|
||||||
|
reading->transits.bodies[a->transiting_planet].house);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
y = add_text(width, y, i18n_get("interp.heading_guidance", "Guidance"), s_font_section);
|
||||||
|
|
||||||
|
/* Attitude/Outcome preview, side by side, image only - no per-card
|
||||||
|
* caption text. A "Position: Card Name (Reversed)" label doesn't fit
|
||||||
|
* a half-width column without wrapping into 3+ short lines, and with
|
||||||
|
* both columns wrapping independently the reader ends up reading
|
||||||
|
* fragments across columns rather than down one column at a time
|
||||||
|
* (confirmed on-device: it reads as one garbled interleaved sentence,
|
||||||
|
* not two). Captions are unnecessary anyway - the guidance text
|
||||||
|
* printed immediately below already names and quotes both cards in
|
||||||
|
* full ("Deine Haltungskarte ist ..." / "Das wahrscheinliche Ergebnis
|
||||||
|
* ... ist ..."), and both reappear with their full position/name/
|
||||||
|
* meaning again in the Celtic Cross walkthrough further down. */
|
||||||
|
{
|
||||||
|
int16_t half_w = width / 2;
|
||||||
|
const TarotDraw *attitude = &reading->spread.positions[POSITION_ATTITUDE];
|
||||||
|
const TarotDraw *outcome = &reading->spread.positions[POSITION_OUTCOME];
|
||||||
|
int16_t img_height = card_image_height_for_width(IMAGE_WIDTH);
|
||||||
|
|
||||||
|
s_repeat_slots[0].card = attitude->card;
|
||||||
|
s_repeat_slots[0].y = y;
|
||||||
|
s_repeat_slots[0].height = img_height;
|
||||||
|
s_repeat_slots[0].bitmap_layer = bitmap_layer_create(GRect(0, y, half_w, img_height));
|
||||||
|
bitmap_layer_set_alignment(s_repeat_slots[0].bitmap_layer, GAlignCenter);
|
||||||
|
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_repeat_slots[0].bitmap_layer));
|
||||||
|
|
||||||
|
s_repeat_slots[1].card = outcome->card;
|
||||||
|
s_repeat_slots[1].y = y;
|
||||||
|
s_repeat_slots[1].height = img_height;
|
||||||
|
s_repeat_slots[1].bitmap_layer =
|
||||||
|
bitmap_layer_create(GRect(half_w, y, width - half_w, img_height));
|
||||||
|
bitmap_layer_set_alignment(s_repeat_slots[1].bitmap_layer, GAlignCenter);
|
||||||
|
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_repeat_slots[1].bitmap_layer));
|
||||||
|
|
||||||
|
y += img_height + MARGIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interp->top_item_count > 0) {
|
||||||
|
y = add_text(width, y, s_transit_titles[0], s_font_subhead);
|
||||||
|
y = add_text(width, y, s_narratives[0], s_font_body);
|
||||||
|
}
|
||||||
|
guidance_write(s_guidance, sizeof s_guidance, "", interp, &reading->spread);
|
||||||
|
y = add_text(width, y, s_guidance, 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++) {
|
||||||
|
y = add_text(width, y, s_transit_titles[i], s_font_subhead);
|
||||||
|
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, reading->gender), 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, reading->gender), s_font_body);
|
||||||
|
y = add_text(width, y, tarot_card_meaning(draw->card, draw->reversed), 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);
|
||||||
|
|
||||||
|
|||||||
+256
-5
File diff suppressed because one or more lines are too long
+15
-9
@@ -61,17 +61,23 @@ def configure(ctx):
|
|||||||
|
|
||||||
|
|
||||||
def build(ctx):
|
def build(ctx):
|
||||||
ctx.load('pebble_sdk')
|
# Regenerate src/c/i18n_tables.auto.c from engine/i18n/*.lang,
|
||||||
|
# resources/img/*.png from res/img/*.jpeg, and resources/app_icon.png
|
||||||
# Regenerate src/c/i18n_tables.auto.c from engine/i18n/*.lang, and
|
# from res/app_icon_50x50_64_color.png, before compiling/bundling
|
||||||
# resources/img/*.png from res/img/*.jpeg, before compiling/bundling
|
# anything - see each script's own doc comment. All three run eagerly
|
||||||
# anything - see each script's own doc comment. Both run eagerly (not
|
# (not as waf Tasks) and must run *before* ctx.load('pebble_sdk')
|
||||||
# as waf Tasks) since the bundle step below depends on their output
|
# below, since loading that tool in a build context immediately packs
|
||||||
# existing; gen_card_images.py skips any card whose PNG is already
|
# resources/ into the app bundle - on a fresh checkout with no
|
||||||
# up to date, so this stays cheap on repeat builds. Requires Pillow
|
# generated resources/img/ yet, that packing step would crash before
|
||||||
# (`pip install Pillow`) - see watch/README.md.
|
# these scripts ever got a chance to create it. gen_card_images.py/
|
||||||
|
# gen_app_icon.py both skip regenerating anything already up to date,
|
||||||
|
# so this stays cheap on repeat builds. Requires Pillow (`pip install
|
||||||
|
# Pillow`) - see watch/README.md.
|
||||||
subprocess.check_call([sys.executable, 'scripts/gen_i18n_tables.py'])
|
subprocess.check_call([sys.executable, 'scripts/gen_i18n_tables.py'])
|
||||||
subprocess.check_call([sys.executable, 'scripts/gen_card_images.py'])
|
subprocess.check_call([sys.executable, 'scripts/gen_card_images.py'])
|
||||||
|
subprocess.check_call([sys.executable, 'scripts/gen_app_icon.py'])
|
||||||
|
|
||||||
|
ctx.load('pebble_sdk')
|
||||||
|
|
||||||
engine_dir = ctx.path.parent.find_dir('engine/src')
|
engine_dir = ctx.path.parent.find_dir('engine/src')
|
||||||
engine_nodes = [engine_dir.find_node(name) for name in ENGINE_SRCS]
|
engine_nodes = [engine_dir.find_node(name) for name in ENGINE_SRCS]
|
||||||
|
|||||||
Reference in New Issue
Block a user