1 Commits

Author SHA1 Message Date
ml 7c60af5142 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()).
2026-07-19 09:13:10 +02:00
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 self-rescheduling one-shot chain, not `AlarmManager`, since a "roughly
this time daily" reminder doesn't need `AlarmManager`'s exact-alarm this time daily" reminder doesn't need `AlarmManager`'s exact-alarm
permission burden) - the Android analog of `watch/src/c/notify.c`'s own 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 **CI needs network access for this target specifically** - unlike the
CLI/Pebble build path above (deliberately zero network access needed 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.ConfigRepository
import de.ladkau.deckinadash.data.ReadingDto import de.ladkau.deckinadash.data.ReadingDto
import de.ladkau.deckinadash.data.ReadingRepository import de.ladkau.deckinadash.data.ReadingRepository
import de.ladkau.deckinadash.util.localizedContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -62,13 +63,14 @@ class ReadingNotificationWorker(
return Result.failure() return Result.failure()
} }
postNotification(reading) postNotification(reading, config.lang)
NotificationScheduler.reschedule(applicationContext, config) NotificationScheduler.reschedule(applicationContext, config)
return Result.success() return Result.success()
} }
private fun postNotification(reading: ReadingDto) { private fun postNotification(reading: ReadingDto, lang: String) {
ensureChannel() val localized = applicationContext.localizedContext(lang)
ensureChannel(localized)
val body = reading.transits.firstOrNull()?.let { "${it.title}. ${it.narrative}" } val body = reading.transits.firstOrNull()?.let { "${it.title}. ${it.narrative}" }
?: reading.guidance ?: reading.guidance
@@ -83,7 +85,7 @@ class ReadingNotificationWorker(
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle( .setContentTitle(
applicationContext.getString(R.string.notification_title, reading.daySignificance.levelLabel), localized.getString(R.string.notification_title, reading.daySignificance.levelLabel),
) )
.setContentText(body) .setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body)) .setStyle(NotificationCompat.BigTextStyle().bigText(body))
@@ -101,15 +103,15 @@ class ReadingNotificationWorker(
NotificationManagerCompat.from(applicationContext).notify(NOTIFICATION_ID, notification) 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 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = applicationContext.getSystemService(NotificationManager::class.java) val manager = applicationContext.getSystemService(NotificationManager::class.java)
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
applicationContext.getString(R.string.notification_channel_name), localized.getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT, NotificationManager.IMPORTANCE_DEFAULT,
).apply { ).apply {
description = applicationContext.getString(R.string.notification_channel_description) description = localized.getString(R.string.notification_channel_description)
} }
manager.createNotificationChannel(channel) 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.TopAppBar
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable 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.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@@ -25,6 +29,8 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import de.ladkau.deckinadash.R 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.about.AboutScreen
import de.ladkau.deckinadash.ui.config.ConfigScreen import de.ladkau.deckinadash.ui.config.ConfigScreen
import de.ladkau.deckinadash.ui.reading.ReadingScreen import de.ladkau.deckinadash.ui.reading.ReadingScreen
@@ -46,24 +52,32 @@ fun DeckInADashApp() {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
// 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( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
drawerContent = { drawerContent = {
ModalDrawerSheet { ModalDrawerSheet {
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_reading)) }, label = { Text(appStringResource(R.string.nav_reading)) },
selected = currentRoute == Destinations.READING, selected = currentRoute == Destinations.READING,
onClick = { navigateTo(navController, Destinations.READING); scope.launch { drawerState.close() } }, onClick = { navigateTo(navController, Destinations.READING); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp), modifier = Modifier.padding(horizontal = 12.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_settings)) }, label = { Text(appStringResource(R.string.nav_settings)) },
selected = currentRoute == Destinations.SETTINGS, selected = currentRoute == Destinations.SETTINGS,
onClick = { navigateTo(navController, Destinations.SETTINGS); scope.launch { drawerState.close() } }, onClick = { navigateTo(navController, Destinations.SETTINGS); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp), modifier = Modifier.padding(horizontal = 12.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_about)) }, label = { Text(appStringResource(R.string.nav_about)) },
selected = currentRoute == Destinations.ABOUT, selected = currentRoute == Destinations.ABOUT,
onClick = { navigateTo(navController, Destinations.ABOUT); scope.launch { drawerState.close() } }, onClick = { navigateTo(navController, Destinations.ABOUT); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp), modifier = Modifier.padding(horizontal = 12.dp),
@@ -74,10 +88,10 @@ fun DeckInADashApp() {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.app_name)) }, title = { Text(appStringResource(R.string.app_name)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) { IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_settings)) Icon(Icons.Filled.Menu, contentDescription = appStringResource(R.string.nav_settings))
} }
}, },
) )
@@ -97,6 +111,7 @@ fun DeckInADashApp() {
} }
} }
} }
}
private fun navigateTo(navController: NavHostController, route: String) { private fun navigateTo(navController: NavHostController, route: String) {
navController.navigate(route) { navController.navigate(route) {
@@ -7,9 +7,9 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.ladkau.deckinadash.R import de.ladkau.deckinadash.R
import de.ladkau.deckinadash.ui.appStringResource
/** Static Waite/Sepharial public-domain attribution, carried forward /** Static Waite/Sepharial public-domain attribution, carried forward
* from the root CLAUDE.md's own "Licensing" section, since this app's * from the root CLAUDE.md's own "Licensing" section, since this app's
@@ -17,7 +17,7 @@ import de.ladkau.deckinadash.R
@Composable @Composable
fun AboutScreen(modifier: Modifier = Modifier) { fun AboutScreen(modifier: Modifier = Modifier) {
Text( Text(
text = stringResource(R.string.about_body), text = appStringResource(R.string.about_body),
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
@@ -20,6 +20,7 @@ import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
@@ -38,13 +39,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.deckinadash.R import de.ladkau.deckinadash.R
import de.ladkau.deckinadash.data.Gender import de.ladkau.deckinadash.data.Gender
import de.ladkau.deckinadash.ui.appStringResource
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -55,6 +56,7 @@ fun ConfigScreen(
viewModel: ConfigViewModel = viewModel(), viewModel: ConfigViewModel = viewModel(),
) { ) {
val config by viewModel.config.collectAsState() val config by viewModel.config.collectAsState()
val isDirty by viewModel.isDirty.collectAsState()
val context = LocalContext.current val context = LocalContext.current
var showDatePicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) }
@@ -74,17 +76,17 @@ fun ConfigScreen(
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(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 }) { Button(onClick = { showDatePicker = true }) {
val label = if (config.birthYear > 0) { val label = if (config.birthYear > 0) {
"%04d-%02d-%02d".format(config.birthYear, config.birthMonth, config.birthDay) "%04d-%02d-%02d".format(config.birthYear, config.birthMonth, config.birthDay)
} else { } else {
stringResource(R.string.config_birth_date) appStringResource(R.string.config_birth_date)
} }
Text(label) 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 }) { Button(onClick = { showBirthTimePicker = true }) {
Text("%02d:%02d".format(config.birthHour, config.birthMinute)) Text("%02d:%02d".format(config.birthHour, config.birthMinute))
} }
@@ -94,7 +96,7 @@ fun ConfigScreen(
onValueChange = { text -> onValueChange = { text ->
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(utcOffsetHours = value) } } 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), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
) )
OutlinedTextField( OutlinedTextField(
@@ -102,7 +104,7 @@ fun ConfigScreen(
onValueChange = { text -> onValueChange = { text ->
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(latitude = value) } } 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), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
) )
OutlinedTextField( OutlinedTextField(
@@ -110,15 +112,15 @@ fun ConfigScreen(
onValueChange = { text -> onValueChange = { text ->
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(longitude = value) } } 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), 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( val genderOptions = listOf(
Gender.UNSPECIFIED to stringResource(R.string.config_gender_unspecified), Gender.UNSPECIFIED to appStringResource(R.string.config_gender_unspecified),
Gender.MALE to stringResource(R.string.config_gender_male), Gender.MALE to appStringResource(R.string.config_gender_male),
Gender.FEMALE to stringResource(R.string.config_gender_female), Gender.FEMALE to appStringResource(R.string.config_gender_female),
) )
SingleChoiceSegmentedButtonRow { SingleChoiceSegmentedButtonRow {
genderOptions.forEachIndexed { index, (gender, label) -> genderOptions.forEachIndexed { index, (gender, label) ->
@@ -136,7 +138,7 @@ fun ConfigScreen(
) )
Row { Row {
Text(stringResource(R.string.config_notify_enabled), style = MaterialTheme.typography.titleSmall) Text(appStringResource(R.string.config_notify_enabled), style = MaterialTheme.typography.titleSmall)
Switch( Switch(
checked = config.notifyEnabled, checked = config.notifyEnabled,
onCheckedChange = { enabled -> onCheckedChange = { enabled ->
@@ -154,8 +156,13 @@ fun ConfigScreen(
} }
} }
Button(onClick = { viewModel.save {} }) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.config_save)) 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 showDatePicker = false
}) { Text(stringResource(R.string.config_save)) } }) { Text(appStringResource(R.string.config_save)) }
}, },
) { DatePicker(state = datePickerState) } ) { DatePicker(state = datePickerState) }
} }
@@ -222,7 +229,7 @@ private fun TimePickerDialogContent(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
confirmButton = { confirmButton = {
TextButton(onClick = { onConfirm(state.hour, state.minute) }) { TextButton(onClick = { onConfirm(state.hour, state.minute) }) {
Text(stringResource(R.string.config_save)) Text(appStringResource(R.string.config_save))
} }
}, },
text = { TimePicker(state = state) }, text = { TimePicker(state = state) },
@@ -239,7 +246,7 @@ private fun LanguageDropdown(selected: String, onSelected: (String) -> Unit) {
value = selected, value = selected,
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text(stringResource(R.string.config_language)) }, label = { Text(appStringResource(R.string.config_language)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), 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.data.DeckConfig
import de.ladkau.deckinadash.notify.NotificationScheduler import de.ladkau.deckinadash.notify.NotificationScheduler
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ConfigViewModel(application: Application) : AndroidViewModel(application) { class ConfigViewModel(application: Application) : AndroidViewModel(application) {
@@ -22,9 +25,20 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
private val _config = MutableStateFlow(DeckConfig()) private val _config = MutableStateFlow(DeckConfig())
val config: StateFlow<DeckConfig> = _config.asStateFlow() 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 { init {
viewModelScope.launch { 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) _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 - /** Persists the current draft and reschedules the daily notification -
* the other of NotificationScheduler.reschedule()'s two required call * the other of NotificationScheduler.reschedule()'s two required call
* sites (see its own doc comment; the first is app launch). */ * sites (see its own doc comment; the first is app launch). */
@@ -39,6 +58,8 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch { viewModelScope.launch {
val toSave = _config.value.copy(configured = true) val toSave = _config.value.copy(configured = true)
configRepository.save(toSave) configRepository.save(toSave)
_config.value = toSave
_savedConfig.value = toSave
NotificationScheduler.reschedule(getApplication(), toSave) NotificationScheduler.reschedule(getApplication(), toSave)
onSaved() onSaved()
} }
@@ -4,7 +4,9 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize 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.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.deckinadash.R import de.ladkau.deckinadash.R
import de.ladkau.deckinadash.data.ReadingDto import de.ladkau.deckinadash.data.ReadingDto
import de.ladkau.deckinadash.data.SpreadPositionDto import de.ladkau.deckinadash.data.SpreadPositionDto
import de.ladkau.deckinadash.data.TransitDto import de.ladkau.deckinadash.data.TransitDto
import de.ladkau.deckinadash.ui.appStringResource
import de.ladkau.deckinadash.ui.cardDrawableRes import de.ladkau.deckinadash.ui.cardDrawableRes
@Composable @Composable
@@ -42,60 +44,93 @@ fun ReadingScreen(
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
when (val state = uiState) { 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( is ReadingUiState.NotConfigured -> Column(
modifier = Modifier.wrapContentSize(Alignment.Center).padding(24.dp), 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)) { 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) is ReadingUiState.Content -> ReadingContent(state.reading)
} }
} }
} }
@Composable @Composable
private fun CenteredMessage(text: String) { private fun CenteredMessage(text: String, loading: Boolean = false) {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
if (text == stringResource(R.string.reading_loading)) { if (loading) {
CircularProgressIndicator(modifier = Modifier.padding(bottom = 16.dp)) CircularProgressIndicator(modifier = Modifier.padding(bottom = 16.dp))
} }
Text(text) Text(text)
} }
} }
/** Single scrollable page, same content order as /** Single scrollable page, mirroring watch/src/c/ui_report_window.c's
* watch/src/c/ui_report_window.c's build_content(): date, day * build_content() section-for-section: date; day significance (with its
* significance, top transits+narratives, full Celtic Cross spread, * rank/count, e.g. "Notable (2/4)"); a "Guidance" section that leads with
* guidance last. */ * 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 @Composable
private fun ReadingContent(reading: ReadingDto) { private fun ReadingContent(reading: ReadingDto) {
val attitude = reading.spread.firstOrNull { it.positionSlug == "attitude" }
val outcome = reading.spread.firstOrNull { it.positionSlug == "outcome" }
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item { item {
Text(reading.date, style = MaterialTheme.typography.labelLarge) Text(reading.date, style = MaterialTheme.typography.labelLarge)
}
item {
Text( 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, style = MaterialTheme.typography.headlineSmall,
) )
} }
if (reading.transits.isNotEmpty()) { item { HorizontalDivider() }
item { item {
Text( Text(
stringResource(R.string.reading_transits_heading), appStringResource(R.string.reading_guidance_heading),
style = MaterialTheme.typography.titleMedium, 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) } items(reading.transits) { transit -> TransitItem(transit) }
} }
@@ -103,22 +138,35 @@ private fun ReadingContent(reading: ReadingDto) {
item { item {
Text( Text(
stringResource(R.string.reading_spread_heading), appStringResource(R.string.reading_spread_heading),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
} }
items(reading.spread) { position -> SpreadItem(position) } items(reading.spread) { position -> SpreadItem(position) }
}
}
item { HorizontalDivider() } @Composable
private fun AttitudeOutcomeRow(attitude: SpreadPositionDto, outcome: SpreadPositionDto) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
AttitudeOutcomeImage(attitude)
AttitudeOutcomeImage(outcome)
}
}
item { @Composable
Text( private fun AttitudeOutcomeImage(position: SpreadPositionDto) {
stringResource(R.string.reading_guidance_heading), val context = LocalContext.current
style = MaterialTheme.typography.titleMedium, Image(
painter = painterResource(cardDrawableRes(context, position.cardSlug)),
contentDescription = position.cardName,
modifier = Modifier
.width(140.dp)
.graphicsLayer { rotationZ = if (position.reversed) 180f else 0f },
) )
Text(reading.guidance, style = MaterialTheme.typography.bodyMedium)
}
}
} }
@Composable @Composable
@@ -145,7 +193,7 @@ private fun SpreadItem(position: SpreadPositionDto) {
.graphicsLayer { rotationZ = if (position.reversed) 180f else 0f }, .graphicsLayer { rotationZ = if (position.reversed) 180f else 0f },
) )
val cardTitle = if (position.reversed) { val cardTitle = if (position.reversed) {
"${position.cardName} ${stringResource(R.string.reading_reversed)}" "${position.cardName} ${appStringResource(R.string.reading_reversed)}"
} else { } else {
position.cardName 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_enabled">Daily notification</string>
<string name="config_notify_time">Notification time</string> <string name="config_notify_time">Notification time</string>
<string name="config_save">Save</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_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_loading">Computing today\'s reading…</string>
<string name="reading_error">Could not compute 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_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_spread_heading">Celtic Cross</string>
<string name="reading_reversed">(Reversed)</string> <string name="reading_reversed">(Reversed)</string>