settings: the hub is Calendula's hub

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.
This commit is contained in:
2026-09-09 15:34:03 +02:00
parent 265de47274
commit 5a79442731
6 changed files with 196 additions and 86 deletions
@@ -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) }
}
@@ -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<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
val options = remember(supported) { listOf<String?>(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<String?>(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 },
)
}
}
@@ -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 ->
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Gitea brand mark, used on the "Source" button in Settings → About.
Single-path logo from Simple Icons (https://simpleicons.org, CC0),
pathData kept verbatim so Android's PathParser reads the arc flags.
fillColor is a placeholder; the Compose Icon recolours it via tint.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z" />
</vector>
+6 -3
View File
@@ -244,9 +244,12 @@
<!-- Settings -->
<string name="settings_title">Settings</string>
<!-- Hub section headers; settings_section_appearance and settings_section_tasks double as them. -->
<string name="settings_section_sync">Sync and data</string>
<string name="settings_section_about">About</string>
<!-- Hub group headers -->
<string name="settings_group_look">Look &amp; behaviour</string>
<string name="settings_group_data">Data</string>
<string name="settings_group_app">App</string>
<!-- Group of reference links at the foot of the settings hub. -->
<string name="settings_group_about">About</string>
<string name="settings_section_appearance">Appearance</string>
<string name="settings_appearance_subtitle">Theme and colour</string>
<string name="settings_section_task_form">Task form</string>