Fix Android config save feedback and mirror watch reading layout
build / build (push) Successful in 3m8s
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:
@@ -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,24 +52,32 @@ fun DeckInADashApp() {
|
||||
val scope = rememberCoroutineScope()
|
||||
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(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.nav_reading)) },
|
||||
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(stringResource(R.string.nav_settings)) },
|
||||
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(stringResource(R.string.nav_about)) },
|
||||
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),
|
||||
@@ -74,10 +88,10 @@ fun DeckInADashApp() {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.app_name)) },
|
||||
title = { Text(appStringResource(R.string.app_name)) },
|
||||
navigationIcon = {
|
||||
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))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -96,6 +110,7 @@ fun DeckInADashApp() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateTo(navController: NavHostController, route: String) {
|
||||
|
||||
@@ -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 { HorizontalDivider() }
|
||||
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.reading_transits_heading),
|
||||
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,22 +138,35 @@ 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() }
|
||||
@Composable
|
||||
private fun AttitudeOutcomeRow(attitude: SpreadPositionDto, outcome: SpreadPositionDto) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
AttitudeOutcomeImage(attitude)
|
||||
AttitudeOutcomeImage(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.reading_guidance_heading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
@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 },
|
||||
)
|
||||
Text(reading.guidance, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user