From 5a79442731607c519c7253d6504aa9f66bfb8873 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 9 Sep 2026 15:34:03 +0200 Subject: [PATCH] settings: the hub is Calendula's hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source and licence were buttons inside the About card, and the groups were named by this app rather than by the one that already solved it. Both now follow Calendula exactly. The card carries the logo, name and author and nothing else, joined to a "Support development" row below it as one grouped block — Position.Top and Position.Bottom, so the call to action continues the container instead of sitting inside it as a button. Source, licence, open source licenses and the privacy policy move to a group of reference links at the foot, above the version mark. The logo takes Calendula's 56dp chip with the 1.5x overscan its adaptive foreground needs. Groups are Look & behaviour / Data / App / About, and the chip accents cycle within each group rather than colouring it uniformly, which is what makes them a scanning aid. The four header keys are Calendula's, so the two apps' catalogues agree. Report a problem also picks up Calendula's crash-report hand-off: a captured report is offered in the same dialog the next-launch prompt uses before falling back to the issue template chooser. The reporter was already installed here; only Settings never reached it. --- .../agendula/ui/settings/SettingsCommon.kt | 66 +++++-- .../agendula/ui/settings/SettingsScreen.kt | 180 ++++++++++++------ .../agendula/ui/settings/TaskFormScreen.kt | 9 +- app/src/main/res/drawable/ic_gitea.xml | 16 ++ app/src/main/res/values/strings.xml | 9 +- floret-kit | 2 +- 6 files changed, 196 insertions(+), 86 deletions(-) create mode 100644 app/src/main/res/drawable/ic_gitea.xml diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsCommon.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsCommon.kt index 67bfdf3..99b828a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsCommon.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsCommon.kt @@ -28,16 +28,21 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.net.toUri import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.floret.components.GroupedListInset /** - * Token-based accent for a leading icon chip (container / on-container pair), - * so each accent stays correctly paired across theme, dark mode and dynamic - * colour. One accent per hub section, so a section reads as a colour band - * rather than nine chips picked one at a time. + * Pieces shared by the settings hub and its sub-screens. Each sub-screen owns + * whatever only it uses; anything two of them need lives here. + */ + +/** + * Accent for a leading icon chip. The chips are a scanning aid, so no two rows + * in one group share an accent; [Neutral] is a step back for reference rows + * rather than a fourth colour to rotate through. */ internal enum class ChipAccent { Neutral, Primary, Secondary, Tertiary } -/** Leading circular icon chip; colours come from the M3 scheme via a token pair. */ +/** Leading circular icon chip, coloured by an M3 container/on-container pair. */ @Composable internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) { val scheme = MaterialTheme.colorScheme @@ -48,30 +53,64 @@ internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) { ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer } Box( - modifier = Modifier.size(40.dp).clip(CircleShape).background(background), + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(background), contentAlignment = Alignment.Center, ) { - Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(22.dp)) + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(22.dp), + ) } } +/** Muted supporting text under a section header, matching the form-fields hint. */ +@Composable +internal fun SettingsHint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp), + ) +} + +/** + * The app icon as a rounded chip: the launcher mark over its background colour, + * oversized and clipped the way a launcher mask would. Sized just past the two + * text lines beside it; [MARK_OVERSCAN] holds the crop as it shrinks. + */ @Composable internal fun AppLogo() { Box( modifier = Modifier - .size(72.dp) - .clip(RoundedCornerShape(20.dp)) + .size(LOGO_SIZE) + .clip(RoundedCornerShape(16.dp)) .background(colorResource(R.color.ic_launcher_background)), contentAlignment = Alignment.Center, ) { Image( painter = painterResource(R.drawable.ic_launcher_foreground), contentDescription = stringResource(R.string.settings_about_logo_desc), - modifier = Modifier.requiredSize(108.dp), + modifier = Modifier.requiredSize(LOGO_SIZE * MARK_OVERSCAN), ) } } +/** Roughly the height of the app name and author lines it sits beside. */ +private val LOGO_SIZE = 56.dp + +/** + * How far the mark overruns its chip. An adaptive icon's foreground carries a + * wide safe margin, so at 1:1 it would sit small and lost; launchers crop it too. + */ +private const val MARK_OVERSCAN = 1.5f + +/** Plain centred version mark at the foot of the settings list (no card). */ @Composable internal fun AppVersionText() { val context = LocalContext.current @@ -84,10 +123,13 @@ internal fun AppVersionText() { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), ) } internal fun openUrl(context: Context, url: String) { - runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) } + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + runCatching { context.startActivity(intent) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt index d879a0b..0ee84be 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt @@ -33,9 +33,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -47,12 +48,16 @@ import de.jeanlucmakiola.agendula.ui.accounts.AddAccountScreen import de.jeanlucmakiola.agendula.ui.export.ExportScreen import de.jeanlucmakiola.agendula.ui.licences.LicencesScreen import de.jeanlucmakiola.floret.components.AboutCard -import de.jeanlucmakiola.floret.components.AboutLink import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedSectionHeader import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.crash.CrashReportDialog +import de.jeanlucmakiola.floret.crash.CrashReporter +import de.jeanlucmakiola.floret.crash.openIssueTracker +import de.jeanlucmakiola.floret.crash.submitCrashReport import de.jeanlucmakiola.floret.locale.AppLanguage /** The settings sub-screens reached from the hub's category rows. */ @@ -188,10 +193,10 @@ private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) { // --------------------------------------------------------------------------- /** - * Four named groups, each a connected run of rows with one accent: what the app - * looks like, what a task looks like, where the tasks live and who syncs them, - * and what the app itself is. The About card stays on top — it is the page's - * identity, not one of the categories. + * Four named groups of category rows, after Calendula's hub: look & behaviour, + * data, app, and a foot of reference links. Source, licence and privacy sit in + * that last group rather than as buttons inside the About card — they are + * reference material, and the card is the app's identity. */ @Composable private fun SettingsHub( @@ -199,48 +204,26 @@ private fun SettingsHub( onOpenSection: (SettingsSection) -> Unit, ) { CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) { - Box(Modifier.padding(horizontal = 16.dp)) { - AboutCard( - logo = { AppLogo() }, - appName = stringResource(R.string.app_name), - author = stringResource(R.string.settings_about_author), - primaryLinks = listOf( - AboutLink( - icon = Icons.Default.Code, - label = stringResource(R.string.settings_about_source), - url = stringResource(R.string.about_source_url), - ), - AboutLink( - icon = Icons.Default.Gavel, - label = stringResource(R.string.settings_license), - url = stringResource(R.string.about_license_url), - ), - ), - highlightLink = AboutLink( - icon = Icons.Default.Favorite, - label = stringResource(R.string.settings_about_support), - url = stringResource(R.string.about_support_url), - ), - ) - } + // Card and support row are one grouped block, so the call to action + // continues the container instead of sitting inside it as a button. + Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() } + SupportRow() + Spacer(Modifier.height(16.dp)) - GroupedSectionHeader(stringResource(R.string.settings_section_appearance)) + // Each group cycles its chip accents so no two rows in it look alike + // (see [ChipAccent]). + GroupedSectionHeader(stringResource(R.string.settings_group_look)) GroupedRow( - // The old row's *summary*, promoted: under an "Appearance" header a - // row also called Appearance says nothing, and this wording is - // already translated everywhere. - title = stringResource(R.string.settings_appearance_subtitle), + title = stringResource(R.string.settings_section_appearance), + summary = stringResource(R.string.settings_appearance_subtitle), position = Position.Top, - leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Primary) }, + leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Secondary) }, onClick = { onOpenSection(SettingsSection.Appearance) }, ) - LanguageRow(position = Position.Bottom, accent = ChipAccent.Primary) - - GroupedSectionHeader(stringResource(R.string.settings_section_tasks)) GroupedRow( title = stringResource(R.string.settings_section_task_form), summary = stringResource(R.string.settings_task_form_subtitle), - position = Position.Top, + position = Position.Middle, leading = { CategoryIcon(Icons.Default.Tune, ChipAccent.Tertiary) }, onClick = { onOpenSection(SettingsSection.TaskForm) }, ) @@ -248,16 +231,17 @@ private fun SettingsHub( title = stringResource(R.string.settings_section_reminders), summary = stringResource(R.string.settings_reminders_subtitle), position = Position.Bottom, - leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Tertiary) }, + leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, onClick = { onOpenSection(SettingsSection.Reminders) }, ) - GroupedSectionHeader(stringResource(R.string.settings_section_sync)) + Spacer(Modifier.height(8.dp)) + GroupedSectionHeader(stringResource(R.string.settings_group_data)) GroupedRow( title = stringResource(R.string.settings_section_accounts), summary = stringResource(R.string.settings_accounts_subtitle), position = Position.Top, - leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Secondary) }, + leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) }, onClick = { onOpenSection(SettingsSection.Accounts) }, ) GroupedRow( @@ -268,24 +252,75 @@ private fun SettingsHub( onClick = { onOpenSection(SettingsSection.Storage) }, ) - GroupedSectionHeader(stringResource(R.string.settings_section_about)) + Spacer(Modifier.height(8.dp)) + GroupedSectionHeader(stringResource(R.string.settings_group_app)) + LanguageRow(position = Position.Top) + ReportProblemRow(position = Position.Bottom) + + // Source, licence and privacy sit at the bottom as reference material + // rather than inside the About card. + Spacer(Modifier.height(8.dp)) + GroupedSectionHeader(stringResource(R.string.settings_group_about)) + AboutLinkRow( + label = stringResource(R.string.settings_about_source), + url = stringResource(R.string.about_source_url), + icon = ImageVector.vectorResource(R.drawable.ic_gitea), + position = Position.Top, + ) + AboutLinkRow( + label = stringResource(R.string.settings_license), + url = stringResource(R.string.about_license_url), + icon = Icons.Default.Gavel, + position = Position.Middle, + ) GroupedRow( title = stringResource(R.string.settings_licences), summary = stringResource(R.string.settings_licences_subtitle), - position = Position.Top, - leading = { CategoryIcon(Icons.Default.Gavel, ChipAccent.Neutral) }, + position = Position.Middle, + leading = { CategoryIcon(Icons.Default.Code, ChipAccent.Neutral) }, onClick = { onOpenSection(SettingsSection.Licences) }, ) - PrivacyPolicyRow(position = Position.Middle) - ReportProblemRow(position = Position.Bottom) + AboutLinkRow( + label = stringResource(R.string.settings_privacy), + url = stringResource(R.string.about_privacy_url), + icon = Icons.Default.PrivacyTip, + position = Position.Bottom, + ) AppVersionText() } } +@Composable +private fun AboutCard() { + // Layout lives in floret-kit (components.AboutCard); Agendula supplies its + // own logo and author. Support is the [SupportRow] joined below. + AboutCard( + logo = { AppLogo() }, + appName = stringResource(R.string.app_name), + author = stringResource(R.string.settings_about_author), + primaryLinks = emptyList(), + position = Position.Top, + ) +} + +/** "Support development", as the closing row of the About card's group. */ +@Composable +private fun SupportRow() { + val context = LocalContext.current + val url = stringResource(R.string.about_support_url) + GroupedRow( + title = stringResource(R.string.settings_about_support), + position = Position.Bottom, + leading = { CategoryIcon(Icons.Default.Favorite, ChipAccent.Primary) }, + onClick = { openUrl(context, url) }, + ) +} + /** - * ⚠️ Linked **in the app**, not only in the Play Console. + * One reference link at the foot of the hub (source, licence, privacy). * + * ⚠️ The privacy policy is linked **in the app**, not only in the Play Console. * Play requires both, and the in-app link is the half that is routinely missed. * Agendula's data-safety declaration is *Collected, not Shared*, encrypted in * transit: Play defines collection as transmitting off-device irrespective of @@ -293,14 +328,13 @@ private fun SettingsHub( * the user's tasks to their own server. */ @Composable -private fun PrivacyPolicyRow(position: Position) { - val uriHandler = LocalUriHandler.current - val privacyUrl = stringResource(R.string.about_privacy_url) +private fun AboutLinkRow(label: String, url: String, icon: ImageVector, position: Position) { + val context = LocalContext.current GroupedRow( - title = stringResource(R.string.settings_privacy), + title = label, position = position, - leading = { CategoryIcon(Icons.Default.PrivacyTip, ChipAccent.Neutral) }, - onClick = { uriHandler.openUri(privacyUrl) }, + leading = { CategoryIcon(icon, ChipAccent.Neutral) }, + onClick = { openUrl(context, url) }, ) } @@ -311,21 +345,22 @@ private fun PrivacyPolicyRow(position: Position) { * not a family primitive. Everything else matches that recipe. */ @Composable -private fun LanguageRow(position: Position, accent: ChipAccent) { +private fun LanguageRow(position: Position) { val context = LocalContext.current + val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) } // Setting a locale recreates the activity; mirror the choice locally so the // row updates instantly even before the recreation lands. - var current by remember { mutableStateOf(AppLanguage.currentTag()) } + var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) } var showDialog by remember { mutableStateOf(false) } // null = follow the system; the rest are BCP-47 tags from locales_config.xml. - val options = remember { listOf(null) + AppLanguage.supportedTags(context, R.xml.locales_config) } + val options = remember(supported) { listOf(null) + supported } GroupedRow( title = stringResource(R.string.settings_language), summary = languageLabel(current), position = position, - leading = { CategoryIcon(Icons.Default.Language, accent) }, + leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) }, onClick = { showDialog = true }, ) @@ -361,16 +396,37 @@ private fun LanguageRow(position: Position, accent: ChipAccent) { private fun languageLabel(tag: String?): String = if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag) -/** Opens the project's issue tracker; no data leaves the device until submitted. */ +/** + * Opens the project's issue tracker to report a problem. If a crash report was + * captured (and not yet sent), it surfaces that report first via the same + * dialog the next-launch prompt uses; otherwise it opens the issue template + * chooser. No data leaves the device until the user submits the issue. + */ @Composable private fun ReportProblemRow(position: Position) { val context = LocalContext.current - val issueUrl = stringResource(R.string.report_issue_url) + var report by remember { mutableStateOf(null) } + GroupedRow( title = stringResource(R.string.settings_report_problem), summary = stringResource(R.string.settings_report_problem_hint), position = position, leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) }, - onClick = { openUrl(context, issueUrl) }, + onClick = { + val pending = CrashReporter.pendingReport(context) + if (pending != null) report = pending else openIssueTracker(context) + }, ) + + report?.let { pending -> + CrashReportDialog( + report = pending, + onSend = { + submitCrashReport(context, pending) + CrashReporter.clearReport(context) + report = null + }, + onDismiss = { report = null }, + ) + } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt index 1eb7924..3c608a0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt @@ -3,7 +3,6 @@ package de.jeanlucmakiola.agendula.ui.settings import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Notes import androidx.compose.material.icons.filled.Notifications @@ -14,7 +13,6 @@ import androidx.compose.material.icons.rounded.Percent import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -43,12 +41,7 @@ internal fun TaskFormScreen( var showDefaultList by remember { mutableStateOf(false) } CollapsingScaffold(title = stringResource(R.string.settings_section_task_form), onBack = onBack) { - Text( - text = stringResource(R.string.settings_form_fields_hint), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) + SettingsHint(stringResource(R.string.settings_form_fields_hint)) Spacer(Modifier.height(8.dp)) val fields = TaskFormField.entries fields.forEachIndexed { index, field -> diff --git a/app/src/main/res/drawable/ic_gitea.xml b/app/src/main/res/drawable/ic_gitea.xml new file mode 100644 index 0000000..d560ae9 --- /dev/null +++ b/app/src/main/res/drawable/ic_gitea.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ef670c..a7b5824 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -244,9 +244,12 @@ Settings - - Sync and data - About + + Look & behaviour + Data + App + + About Appearance Theme and colour Task form diff --git a/floret-kit b/floret-kit index 1af2484..97b4d24 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 1af24841ae8e670fb608253086f8c702d4f7ba09 +Subproject commit 97b4d24724170b1f15a4a401d7a908cea5e3b05e