From 7c60af51422c73567e83817ce3fb2663ff8c5f4a Mon Sep 17 00:00:00 2001 From: ml Date: Sun, 19 Jul 2026 09:13:10 +0200 Subject: [PATCH] Fix Android config save feedback and mirror watch reading layout 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()). --- CLAUDE.md | 25 +++- .../notify/ReadingNotificationWorker.kt | 16 +-- .../de/ladkau/deckinadash/ui/AppStrings.kt | 30 +++++ .../ladkau/deckinadash/ui/DeckInADashApp.kt | 109 +++++++++-------- .../deckinadash/ui/about/AboutScreen.kt | 4 +- .../deckinadash/ui/config/ConfigScreen.kt | 41 ++++--- .../deckinadash/ui/config/ConfigViewModel.kt | 23 +++- .../deckinadash/ui/reading/ReadingScreen.kt | 110 +++++++++++++----- .../de/ladkau/deckinadash/util/LocaleUtils.kt | 23 ++++ .../app/src/main/res/values-de/strings.xml | 46 ++++++++ android/app/src/main/res/values/strings.xml | 5 +- 11 files changed, 325 insertions(+), 107 deletions(-) create mode 100644 android/app/src/main/java/de/ladkau/deckinadash/ui/AppStrings.kt create mode 100644 android/app/src/main/java/de/ladkau/deckinadash/util/LocaleUtils.kt create mode 100644 android/app/src/main/res/values-de/strings.xml diff --git a/CLAUDE.md b/CLAUDE.md index ba097e2..a625a3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/android/app/src/main/java/de/ladkau/deckinadash/notify/ReadingNotificationWorker.kt b/android/app/src/main/java/de/ladkau/deckinadash/notify/ReadingNotificationWorker.kt index f286cc4..555eef8 100644 --- a/android/app/src/main/java/de/ladkau/deckinadash/notify/ReadingNotificationWorker.kt +++ b/android/app/src/main/java/de/ladkau/deckinadash/notify/ReadingNotificationWorker.kt @@ -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) } diff --git a/android/app/src/main/java/de/ladkau/deckinadash/ui/AppStrings.kt b/android/app/src/main/java/de/ladkau/deckinadash/ui/AppStrings.kt new file mode 100644 index 0000000..4d69574 --- /dev/null +++ b/android/app/src/main/java/de/ladkau/deckinadash/ui/AppStrings.kt @@ -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) diff --git a/android/app/src/main/java/de/ladkau/deckinadash/ui/DeckInADashApp.kt b/android/app/src/main/java/de/ladkau/deckinadash/ui/DeckInADashApp.kt index e34d695..2fe595b 100644 --- a/android/app/src/main/java/de/ladkau/deckinadash/ui/DeckInADashApp.kt +++ b/android/app/src/main/java/de/ladkau/deckinadash/ui/DeckInADashApp.kt @@ -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() } } } } diff --git a/android/app/src/main/java/de/ladkau/deckinadash/ui/about/AboutScreen.kt b/android/app/src/main/java/de/ladkau/deckinadash/ui/about/AboutScreen.kt index a66b683..dc407d8 100644 --- a/android/app/src/main/java/de/ladkau/deckinadash/ui/about/AboutScreen.kt +++ b/android/app/src/main/java/de/ladkau/deckinadash/ui/about/AboutScreen.kt @@ -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()) diff --git a/android/app/src/main/java/de/ladkau/deckinadash/ui/config/ConfigScreen.kt b/android/app/src/main/java/de/ladkau/deckinadash/ui/config/ConfigScreen.kt index 0864c51..2159ff4 100644 --- a/android/app/src/main/java/de/ladkau/deckinadash/ui/config/ConfigScreen.kt +++ b/android/app/src/main/java/de/ladkau/deckinadash/ui/config/ConfigScreen.kt @@ -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), ) diff --git a/android/app/src/main/java/de/ladkau/deckinadash/ui/config/ConfigViewModel.kt b/android/app/src/main/java/de/ladkau/deckinadash/ui/config/ConfigViewModel.kt index 5988828..2b4bb5a 100644 --- a/android/app/src/main/java/de/ladkau/deckinadash/ui/config/ConfigViewModel.kt +++ b/android/app/src/main/java/de/ladkau/deckinadash/ui/config/ConfigViewModel.kt @@ -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 = _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 = 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() } diff --git a/android/app/src/main/java/de/ladkau/deckinadash/ui/reading/ReadingScreen.kt b/android/app/src/main/java/de/ladkau/deckinadash/ui/reading/ReadingScreen.kt index ccbd78a..4a47718 100644 --- a/android/app/src/main/java/de/ladkau/deckinadash/ui/reading/ReadingScreen.kt +++ b/android/app/src/main/java/de/ladkau/deckinadash/ui/reading/ReadingScreen.kt @@ -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 } diff --git a/android/app/src/main/java/de/ladkau/deckinadash/util/LocaleUtils.kt b/android/app/src/main/java/de/ladkau/deckinadash/util/LocaleUtils.kt new file mode 100644 index 0000000..c33affc --- /dev/null +++ b/android/app/src/main/java/de/ladkau/deckinadash/util/LocaleUtils.kt @@ -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-/ 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) +} diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..c25b374 --- /dev/null +++ b/android/app/src/main/res/values-de/strings.xml @@ -0,0 +1,46 @@ + + + + Deck in a Dash + + Heutige Lesung + Einstellungen + Über + + Tägliche Lesung + Eine tägliche Benachrichtigung mit deiner Tarot- und Astrologie-Lesung + Heutige Lesung: %1$s + + Einstellungen + Geburtsdatum + Geburtszeit + UTC-Versatz (Stunden) + Breitengrad + Längengrad + Lesung für + Nicht angegeben + Männlich + Weiblich + Sprache + Tägliche Benachrichtigung + Benachrichtigungszeit + Speichern + Verwerfen + + Trage deine Geburtsdaten in den Einstellungen ein, um die heutige Lesung zu sehen. + Berechne die heutige Lesung… + Die heutige Lesung konnte nicht berechnet werden. + Bedeutung des Tages + Rat für heute + Wichtige Transits + Heute keine nennenswerten Transits. + Keltisches Kreuz + (Umgekehrt) + + 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). + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index b5dc786..4a88870 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -24,12 +24,15 @@ Daily notification Notification time Save + Cancel Set your birth details in Settings to see today\'s reading. Computing today\'s reading… Could not compute today\'s reading. + Day Significance Guidance - Significant transits + Significant Transits + No notable transits today. Celtic Cross (Reversed)