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