Add a standalone Android app with the same daily reading and config page
build / build (push) Successful in 4m8s
build / build (push) Successful in 4m8s
android/ links engine/src/ and interpreter/src/ via JNI/CMake (real astronomy.c, not the watch's size-constrained substitute), rendering through Jetpack Compose with a burger-menu config page mirroring WatchConfig and a WorkManager-driven daily notification. Independent of the watch app - neither requires the other. build-image now bundles the Android SDK/NDK alongside the Pebble SDK (VERSION bumped to 2), and .gitea/workflows/build.yml/Makefile/ run-image.sh build and publish the APK the same way as the CLI tarball and .pbw.
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user