Fix Android config save feedback and mirror watch reading layout
build / build (push) Successful in 3m8s

Gray out Save until settings are dirty, add a Cancel button that
discards edits, reorder ReadingScreen to match the watch's
build_content() (adds the missing Attitude/Outcome preview and the
day-significance rank/count), and fix UI chrome to follow the app's
own language setting instead of system locale (values-de/strings.xml
+ appStringResource()/localizedContext()).
This commit is contained in:
ml
2026-07-19 09:13:10 +02:00
parent 3bc1263294
commit 7c60af5142
11 changed files with 325 additions and 107 deletions
+24 -1
View File
@@ -179,7 +179,30 @@ 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.
daily-wakeup contract. `ReadingScreen`'s layout mirrors
`watch/src/c/ui_report_window.c`'s `build_content()` section-for-section
(date → day significance, shown with its rank/count e.g. "Notable
(2/4)" → a Guidance section leading with an Attitude/Outcome card
preview, image only, then the day's top transit, then the guidance
paragraph → the full significant-transits list → the full Celtic Cross
spread), not the interpreter's own `--format html` layout, which orders
sections differently and is a separate, standalone rendering path.
The compiled-in `.lang` strings the JNI bridge already localizes (card
text, narrative, guidance) don't cover the Compose UI's own chrome
(nav labels, section headings, notification text) - those are ordinary
Android string resources, which by default resolve against the
*device's* system locale, not `DeckConfig.lang`. Since this app's
reading language is an explicit in-Settings choice independent of the
system locale (`android/app/src/main/res/values-de/strings.xml` holds
the German chrome strings), every chrome string is read through
`ui/AppStrings.kt`'s `appStringResource()` instead of Compose's own
`stringResource()` - it wraps the `Context` in a `Configuration`
forced to `LocalAppLanguage` (provided once, at the root, from
`ConfigRepository`'s `DeckConfig.lang`) via
`util/LocaleUtils.kt`'s `Context.localizedContext()`, the same helper
`ReadingNotificationWorker` uses for its own notification/channel text
outside Compose entirely.
**CI needs network access for this target specifically** - unlike the
CLI/Pebble build path above (deliberately zero network access needed
@@ -18,6 +18,7 @@ import de.ladkau.deckinadash.R
import de.ladkau.deckinadash.data.ConfigRepository
import de.ladkau.deckinadash.data.ReadingDto
import de.ladkau.deckinadash.data.ReadingRepository
import de.ladkau.deckinadash.util.localizedContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@@ -62,13 +63,14 @@ class ReadingNotificationWorker(
return Result.failure()
}
postNotification(reading)
postNotification(reading, config.lang)
NotificationScheduler.reschedule(applicationContext, config)
return Result.success()
}
private fun postNotification(reading: ReadingDto) {
ensureChannel()
private fun postNotification(reading: ReadingDto, lang: String) {
val localized = applicationContext.localizedContext(lang)
ensureChannel(localized)
val body = reading.transits.firstOrNull()?.let { "${it.title}. ${it.narrative}" }
?: reading.guidance
@@ -83,7 +85,7 @@ class ReadingNotificationWorker(
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(
applicationContext.getString(R.string.notification_title, reading.daySignificance.levelLabel),
localized.getString(R.string.notification_title, reading.daySignificance.levelLabel),
)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
@@ -101,15 +103,15 @@ class ReadingNotificationWorker(
NotificationManagerCompat.from(applicationContext).notify(NOTIFICATION_ID, notification)
}
private fun ensureChannel() {
private fun ensureChannel(localized: Context) {
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),
localized.getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = applicationContext.getString(R.string.notification_channel_description)
description = localized.getString(R.string.notification_channel_description)
}
manager.createNotificationChannel(channel)
}
@@ -0,0 +1,30 @@
package de.ladkau.deckinadash.ui
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import de.ladkau.deckinadash.util.localizedContext
/** The reading language selected in Settings (DeckConfig.lang) - provided
* once near the root (see DeckInADashApp) and read by every screen's
* appStringResource() call below instead of Compose's own stringResource(),
* which always follows the device's system locale rather than this
* in-app setting. */
val LocalAppLanguage = compositionLocalOf { "en" }
@Composable
private fun rememberLocalizedContext(): Context {
val lang = LocalAppLanguage.current
val base = LocalContext.current
return remember(base, lang) { base.localizedContext(lang) }
}
@Composable
fun appStringResource(@StringRes id: Int): String = rememberLocalizedContext().getString(id)
@Composable
fun appStringResource(@StringRes id: Int, vararg formatArgs: Any): String =
rememberLocalizedContext().getString(id, *formatArgs)
@@ -15,9 +15,13 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
@@ -25,6 +29,8 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import de.ladkau.deckinadash.R
import de.ladkau.deckinadash.data.ConfigRepository
import de.ladkau.deckinadash.data.DeckConfig
import de.ladkau.deckinadash.ui.about.AboutScreen
import de.ladkau.deckinadash.ui.config.ConfigScreen
import de.ladkau.deckinadash.ui.reading.ReadingScreen
@@ -46,53 +52,62 @@ fun DeckInADashApp() {
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) })
// Provided here, at the root, so every screen's appStringResource()
// call below picks up the configured reading language rather than
// the device's system locale - see AppStrings.kt's own comment.
val context = LocalContext.current
val configRepository = remember { ConfigRepository(context) }
val appConfig by configRepository.config.collectAsState(initial = DeckConfig())
CompositionLocalProvider(LocalAppLanguage provides appConfig.lang) {
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
NavigationDrawerItem(
label = { Text(appStringResource(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(appStringResource(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(appStringResource(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(appStringResource(R.string.app_name)) },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = appStringResource(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() }
}
composable(Destinations.SETTINGS) { ConfigScreen() }
composable(Destinations.ABOUT) { AboutScreen() }
}
}
}
@@ -7,9 +7,9 @@ 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
import de.ladkau.deckinadash.ui.appStringResource
/** Static Waite/Sepharial public-domain attribution, carried forward
* from the root CLAUDE.md's own "Licensing" section, since this app's
@@ -17,7 +17,7 @@ import de.ladkau.deckinadash.R
@Composable
fun AboutScreen(modifier: Modifier = Modifier) {
Text(
text = stringResource(R.string.about_body),
text = appStringResource(R.string.about_body),
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
@@ -20,6 +20,7 @@ import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
@@ -38,13 +39,13 @@ 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 de.ladkau.deckinadash.ui.appStringResource
import java.time.Instant
import java.time.ZoneOffset
@@ -55,6 +56,7 @@ fun ConfigScreen(
viewModel: ConfigViewModel = viewModel(),
) {
val config by viewModel.config.collectAsState()
val isDirty by viewModel.isDirty.collectAsState()
val context = LocalContext.current
var showDatePicker by remember { mutableStateOf(false) }
@@ -74,17 +76,17 @@ fun ConfigScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(stringResource(R.string.config_birth_date), style = MaterialTheme.typography.titleSmall)
Text(appStringResource(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)
appStringResource(R.string.config_birth_date)
}
Text(label)
}
Text(stringResource(R.string.config_birth_time), style = MaterialTheme.typography.titleSmall)
Text(appStringResource(R.string.config_birth_time), style = MaterialTheme.typography.titleSmall)
Button(onClick = { showBirthTimePicker = true }) {
Text("%02d:%02d".format(config.birthHour, config.birthMinute))
}
@@ -94,7 +96,7 @@ fun ConfigScreen(
onValueChange = { text ->
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(utcOffsetHours = value) } }
},
label = { Text(stringResource(R.string.config_utc_offset)) },
label = { Text(appStringResource(R.string.config_utc_offset)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
@@ -102,7 +104,7 @@ fun ConfigScreen(
onValueChange = { text ->
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(latitude = value) } }
},
label = { Text(stringResource(R.string.config_latitude)) },
label = { Text(appStringResource(R.string.config_latitude)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
@@ -110,15 +112,15 @@ fun ConfigScreen(
onValueChange = { text ->
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(longitude = value) } }
},
label = { Text(stringResource(R.string.config_longitude)) },
label = { Text(appStringResource(R.string.config_longitude)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
)
Text(stringResource(R.string.config_gender), style = MaterialTheme.typography.titleSmall)
Text(appStringResource(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),
Gender.UNSPECIFIED to appStringResource(R.string.config_gender_unspecified),
Gender.MALE to appStringResource(R.string.config_gender_male),
Gender.FEMALE to appStringResource(R.string.config_gender_female),
)
SingleChoiceSegmentedButtonRow {
genderOptions.forEachIndexed { index, (gender, label) ->
@@ -136,7 +138,7 @@ fun ConfigScreen(
)
Row {
Text(stringResource(R.string.config_notify_enabled), style = MaterialTheme.typography.titleSmall)
Text(appStringResource(R.string.config_notify_enabled), style = MaterialTheme.typography.titleSmall)
Switch(
checked = config.notifyEnabled,
onCheckedChange = { enabled ->
@@ -154,8 +156,13 @@ fun ConfigScreen(
}
}
Button(onClick = { viewModel.save {} }) {
Text(stringResource(R.string.config_save))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(onClick = { viewModel.cancel() }, enabled = isDirty) {
Text(appStringResource(R.string.config_cancel))
}
Button(onClick = { viewModel.save {} }, enabled = isDirty) {
Text(appStringResource(R.string.config_save))
}
}
}
@@ -179,7 +186,7 @@ fun ConfigScreen(
}
}
showDatePicker = false
}) { Text(stringResource(R.string.config_save)) }
}) { Text(appStringResource(R.string.config_save)) }
},
) { DatePicker(state = datePickerState) }
}
@@ -222,7 +229,7 @@ private fun TimePickerDialogContent(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = { onConfirm(state.hour, state.minute) }) {
Text(stringResource(R.string.config_save))
Text(appStringResource(R.string.config_save))
}
},
text = { TimePicker(state = state) },
@@ -239,7 +246,7 @@ private fun LanguageDropdown(selected: String, onSelected: (String) -> Unit) {
value = selected,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.config_language)) },
label = { Text(appStringResource(R.string.config_language)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
@@ -7,9 +7,12 @@ 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.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
@@ -22,9 +25,20 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
private val _config = MutableStateFlow(DeckConfig())
val config: StateFlow<DeckConfig> = _config.asStateFlow()
// The last value actually persisted (or loaded from DataStore) -
// compared against the draft above to drive the Save/Cancel buttons'
// enabled state, so Save visibly grays out once there's nothing to
// save rather than always looking clickable.
private val _savedConfig = MutableStateFlow(DeckConfig())
val isDirty: StateFlow<Boolean> = combine(_config, _savedConfig) { draft, saved -> draft != saved }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
init {
viewModelScope.launch {
_config.value = configRepository.config.first()
val loaded = configRepository.config.first()
_config.value = loaded
_savedConfig.value = loaded
}
}
@@ -32,6 +46,11 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
_config.value = transform(_config.value)
}
/** Discards the current draft, reverting it to the last persisted value. */
fun cancel() {
_config.value = _savedConfig.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). */
@@ -39,6 +58,8 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
val toSave = _config.value.copy(configured = true)
configRepository.save(toSave)
_config.value = toSave
_savedConfig.value = toSave
NotificationScheduler.reschedule(getApplication(), toSave)
onSaved()
}
@@ -4,7 +4,9 @@ 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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
@@ -23,13 +25,13 @@ 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.appStringResource
import de.ladkau.deckinadash.ui.cardDrawableRes
@Composable
@@ -42,60 +44,93 @@ fun ReadingScreen(
Box(modifier = modifier.fillMaxSize()) {
when (val state = uiState) {
is ReadingUiState.Loading -> CenteredMessage(stringResource(R.string.reading_loading))
is ReadingUiState.Loading -> CenteredMessage(appStringResource(R.string.reading_loading), loading = true)
is ReadingUiState.NotConfigured -> Column(
modifier = Modifier.wrapContentSize(Alignment.Center).padding(24.dp),
) {
Text(stringResource(R.string.reading_not_configured))
Text(appStringResource(R.string.reading_not_configured))
Button(onClick = onNavigateToSettings, modifier = Modifier.padding(top = 16.dp)) {
Text(stringResource(R.string.nav_settings))
Text(appStringResource(R.string.nav_settings))
}
}
is ReadingUiState.Error -> CenteredMessage(stringResource(R.string.reading_error))
is ReadingUiState.Error -> CenteredMessage(appStringResource(R.string.reading_error))
is ReadingUiState.Content -> ReadingContent(state.reading)
}
}
}
@Composable
private fun CenteredMessage(text: String) {
private fun CenteredMessage(text: String, loading: Boolean = false) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (text == stringResource(R.string.reading_loading)) {
if (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. */
/** Single scrollable page, mirroring watch/src/c/ui_report_window.c's
* build_content() section-for-section: date; day significance (with its
* rank/count, e.g. "Notable (2/4)"); a "Guidance" section that leads with
* the Attitude/Outcome card preview (image only, no caption - same
* reasoning as that file's own comment) followed by the day's single
* top transit and the guidance paragraph; the full list of significant
* transits; and finally the full Celtic Cross spread. */
@Composable
private fun ReadingContent(reading: ReadingDto) {
val attitude = reading.spread.firstOrNull { it.positionSlug == "attitude" }
val outcome = reading.spread.firstOrNull { it.positionSlug == "outcome" }
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text(reading.date, style = MaterialTheme.typography.labelLarge)
}
item {
Text(
reading.daySignificance.levelLabel,
appStringResource(R.string.reading_day_significance_heading),
style = MaterialTheme.typography.titleMedium,
)
Text(
"${reading.daySignificance.levelLabel} (${reading.daySignificance.rank}/${reading.daySignificance.count})",
style = MaterialTheme.typography.headlineSmall,
)
}
if (reading.transits.isNotEmpty()) {
item {
Text(
stringResource(R.string.reading_transits_heading),
style = MaterialTheme.typography.titleMedium,
)
}
item { HorizontalDivider() }
item {
Text(
appStringResource(R.string.reading_guidance_heading),
style = MaterialTheme.typography.titleMedium,
)
}
if (attitude != null && outcome != null) {
item { AttitudeOutcomeRow(attitude, outcome) }
}
reading.transits.firstOrNull()?.let { topTransit ->
item { TransitItem(topTransit) }
}
item { Text(reading.guidance, style = MaterialTheme.typography.bodyMedium) }
item { HorizontalDivider() }
item {
Text(
appStringResource(R.string.reading_transits_heading),
style = MaterialTheme.typography.titleMedium,
)
}
if (reading.transits.isEmpty()) {
item { Text(appStringResource(R.string.reading_no_notable_transits)) }
} else {
items(reading.transits) { transit -> TransitItem(transit) }
}
@@ -103,24 +138,37 @@ private fun ReadingContent(reading: ReadingDto) {
item {
Text(
stringResource(R.string.reading_spread_heading),
appStringResource(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 AttitudeOutcomeRow(attitude: SpreadPositionDto, outcome: SpreadPositionDto) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
AttitudeOutcomeImage(attitude)
AttitudeOutcomeImage(outcome)
}
}
@Composable
private fun AttitudeOutcomeImage(position: SpreadPositionDto) {
val context = LocalContext.current
Image(
painter = painterResource(cardDrawableRes(context, position.cardSlug)),
contentDescription = position.cardName,
modifier = Modifier
.width(140.dp)
.graphicsLayer { rotationZ = if (position.reversed) 180f else 0f },
)
}
@Composable
private fun TransitItem(transit: TransitDto) {
Column {
@@ -145,7 +193,7 @@ private fun SpreadItem(position: SpreadPositionDto) {
.graphicsLayer { rotationZ = if (position.reversed) 180f else 0f },
)
val cardTitle = if (position.reversed) {
"${position.cardName} ${stringResource(R.string.reading_reversed)}"
"${position.cardName} ${appStringResource(R.string.reading_reversed)}"
} else {
position.cardName
}
@@ -0,0 +1,23 @@
package de.ladkau.deckinadash.util
import android.content.Context
import android.content.res.Configuration
import java.util.Locale
/**
* Returns a Context whose string resources are resolved for [lang],
* independent of the device's system locale. DeckConfig.lang is a
* user-chosen reading language (see ConfigScreen's language dropdown),
* separate from whatever locale the phone itself is set to - the same way
* the native engine's i18n_get() already renders card/narrative/guidance
* text in that language regardless of system locale. Without this, Compose's
* own stringResource()/Context.getString() would resolve UI chrome text
* (headings, notification text) against the system locale's resources
* instead, which for a language with no values-<lang>/ directory at all
* would render as English even when the reading itself is in German.
*/
fun Context.localizedContext(lang: String): Context {
val config = Configuration(resources.configuration)
config.setLocale(Locale.forLanguageTag(lang))
return createConfigurationContext(config)
}
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- German translation of the UI chrome text in ../values/strings.xml -
keys must match exactly, same convention as engine/i18n/de.lang.
Read either via the system locale (a German-locale device) or,
primarily, via LocaleUtils.kt's localizedContext(lang) so the app's
own in-Settings reading-language choice (independent of the device's
system locale) picks these up - see ui/AppStrings.kt. -->
<resources>
<string name="app_name">Deck in a Dash</string>
<string name="nav_reading">Heutige Lesung</string>
<string name="nav_settings">Einstellungen</string>
<string name="nav_about">Über</string>
<string name="notification_channel_name">Tägliche Lesung</string>
<string name="notification_channel_description">Eine tägliche Benachrichtigung mit deiner Tarot- und Astrologie-Lesung</string>
<string name="notification_title">Heutige Lesung: %1$s</string>
<string name="config_title">Einstellungen</string>
<string name="config_birth_date">Geburtsdatum</string>
<string name="config_birth_time">Geburtszeit</string>
<string name="config_utc_offset">UTC-Versatz (Stunden)</string>
<string name="config_latitude">Breitengrad</string>
<string name="config_longitude">Längengrad</string>
<string name="config_gender">Lesung für</string>
<string name="config_gender_unspecified">Nicht angegeben</string>
<string name="config_gender_male">Männlich</string>
<string name="config_gender_female">Weiblich</string>
<string name="config_language">Sprache</string>
<string name="config_notify_enabled">Tägliche Benachrichtigung</string>
<string name="config_notify_time">Benachrichtigungszeit</string>
<string name="config_save">Speichern</string>
<string name="config_cancel">Verwerfen</string>
<string name="reading_not_configured">Trage deine Geburtsdaten in den Einstellungen ein, um die heutige Lesung zu sehen.</string>
<string name="reading_loading">Berechne die heutige Lesung…</string>
<string name="reading_error">Die heutige Lesung konnte nicht berechnet werden.</string>
<string name="reading_day_significance_heading">Bedeutung des Tages</string>
<string name="reading_guidance_heading">Rat für heute</string>
<string name="reading_transits_heading">Wichtige Transits</string>
<string name="reading_no_notable_transits">Heute keine nennenswerten Transits.</string>
<string name="reading_spread_heading">Keltisches Kreuz</string>
<string name="reading_reversed">(Umgekehrt)</string>
<string name="about_body">Deck in a Dash zeigt eine tägliche, persönliche Tarot-Lesung (Keltisches Kreuz) und Astrologie-Deutung (Geburtshoroskop + Transite).\n\nKarten- und Legetext basiert auf A. E. Waites The Pictorial Key to the Tarot (1911, gemeinfrei). Der Transit-Text basiert auf Sepharials Transits and Planetary Periods (1920, gemeinfrei).</string>
</resources>
+4 -1
View File
@@ -24,12 +24,15 @@
<string name="config_notify_enabled">Daily notification</string>
<string name="config_notify_time">Notification time</string>
<string name="config_save">Save</string>
<string name="config_cancel">Cancel</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_day_significance_heading">Day Significance</string>
<string name="reading_guidance_heading">Guidance</string>
<string name="reading_transits_heading">Significant transits</string>
<string name="reading_transits_heading">Significant Transits</string>
<string name="reading_no_notable_transits">No notable transits today.</string>
<string name="reading_spread_heading">Celtic Cross</string>
<string name="reading_reversed">(Reversed)</string>