From 210ddff8d8776e86ef781f98de3a4ae91de78486 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 16 Jun 2026 11:44:10 +0200 Subject: [PATCH] =?UTF-8?q?release:=20cut=20v2.3.0=20=E2=80=94=20Material?= =?UTF-8?q?=203=20grouped-list=20redesign=20of=20Settings,=20calendars=20&?= =?UTF-8?q?=20drawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One shared Material 3 grouped-list blueprint, modelled on the ReFra gallery app and extracted to ui/common/GroupedList.kt: CollapsingScaffold (a LargeTopAppBar whose large title collapses into the bar on scroll) and GroupedRow (Position-based corner grouping so a run of rows reads as one rounded card, with press-animated corners and selected/minHeight knobs). Settings: restructured into a category hub (About card on top, version mark at the foot) with sliding sub-pages for Appearance, the new-event form and Notifications. Theme, week-start and language pickers migrated from DropdownMenu to OptionCard dialogs; token-based icon chips. New ic_gitea.xml (Simple Icons, verbatim path) for the About "Source" button; en+de strings. Calendar manager: same collapsing scaffold + grouped rows; shared CalendarColorChip (neutral chip with a pastelised calendar glyph) replaces the bright colour swatch. Navigation drawer: branded header, grouped View switcher (active view highlighted via secondaryContainer), filter list restyled to grouped rows with a trailing checkbox; the whole drawer now scrolls as one. Cards use surfaceContainerHigh for readable contrast against surface. Version bumped to 2.3.0 / 20300. UI-only; unit tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 28 +- .planning/STATE.md | 34 +- CHANGELOG.md | 18 + app/build.gradle.kts | 4 +- .../calendula/ui/calendars/CalendarsScreen.kt | 218 ++--- .../calendula/ui/common/CalendarColors.kt | 38 + .../calendula/ui/common/CalendarDrawer.kt | 111 ++- .../calendula/ui/common/GroupedList.kt | 191 +++++ .../calendula/ui/filter/CalendarFilterList.kt | 85 +- .../calendula/ui/settings/SettingsScreen.kt | 771 ++++++++++-------- app/src/main/res/drawable/ic_gitea.xml | 16 + app/src/main/res/values-de/strings.xml | 11 +- app/src/main/res/values/strings.xml | 12 +- 13 files changed, 960 insertions(+), 577 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt create mode 100644 app/src/main/res/drawable/ic_gitea.xml diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 72c0845..60ea747 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -126,6 +126,23 @@ Deliberately deferred (add only if needed): - Shared `InlineTextField` extracted to `ui.common` (event form + calendar editor share one input style) +## v2.3 — Material 3 grouped-list redesign (shipped 2026-06-16) + +A structural + visual pass adopting one shared blueprint (modelled on the ReFra +gallery app) across Settings, the calendar manager and the navigation drawer. + +- Shared `ui/common/GroupedList.kt`: `CollapsingScaffold` (a `LargeTopAppBar` + whose title collapses on scroll) + `GroupedRow` (Position-based corner + grouping, press-animated corners, `selected` + `minHeight` knobs). +- Settings: category hub with About card on top and sliding sub-pages + (Appearance / New event form / Notifications); theme/week-start/language + pickers moved from `DropdownMenu` to OptionCard dialogs; token-based icon + chips; `ic_gitea.xml` for the About "Source" button. +- Calendar manager + drawer restyled to match; shared `CalendarColorChip`; + drawer scrolls as one with the active view highlighted. +- Cards use `surfaceContainerHigh` for readable contrast. +- Donate button on the About card deferred (target TBD). + --- # Backlog (theme-based, post-v2.1) @@ -146,13 +163,18 @@ unblock a later item. Order is a plan, not a contract — revisit after each lan **Tier 1 — finish the current arc (create/edit + calendars)** 1. Tap-to-create in day/week *(shipped v2.2.0)* — prefilled create from an empty slot 2. Local calendar management + "manage in source app" deep-links *(shipped v2.2.0)* -3. **Settings redesign & restructure** *(next, high prio)* — see scope below -4. Per-event color — reuses the calendar color picker/palette; closes the create/edit theme +3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full + grouped-list blueprint across Settings + calendars + drawer; see "v2.3" + above)* +4. **Per-event color** *(next)* — reuses the calendar color picker/palette; closes the create/edit theme 5. Duplicate event — detail action → prefilled create form; near-free on the tap-to-create prefill infra (Tier 2+ numbering below shifts accordingly; ranking unchanged.) -### Settings redesign & restructure *(next, high prio)* +### Settings redesign & restructure *(shipped v2.3.0)* + +The original scope below is kept as a record; the implementation expanded from a +sub-screen restructure into the shared grouped-list blueprint (see "v2.3" above). The settings screen has grown into a flat vertical scroll of divider-separated sections (Appearance, Event form, Notifications, Calendars, Language, About) and diff --git a/.planning/STATE.md b/.planning/STATE.md index e756d9b..96fc10d 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -7,8 +7,9 @@ **Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11; v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15. **Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local -calendar management with per-calendar "manage in source app" deep-links) -shipped 2026-06-16. The backlog is now organised by theme in `ROADMAP.md`. +calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings, +the calendar manager and the navigation drawer) both shipped 2026-06-16. The +backlog is now organised by theme in `ROADMAP.md`. ## Progress @@ -86,18 +87,31 @@ shipped 2026-06-16. The backlog is now organised by theme in `ROADMAP.md`. deep-link (resolved from the account's authenticator: DAVx5/ICSx5/…) and an add-account shortcut. Shared `InlineTextField` extracted to `ui.common` +- [x] v2.3 settings/calendars/drawer redesign (shipped 2026-06-16) — adopted a + shared Material 3 grouped-list blueprint, modelled on the ReFra gallery app + and extracted to `ui/common/GroupedList.kt` (`CollapsingScaffold` with a + `LargeTopAppBar` exit-until-collapsed title; `GroupedRow` with Position-based + corner grouping, press-animated corners, `selected` + `minHeight` knobs). + - Settings: category hub (About card on top → version mark at the foot) with + sliding sub-pages (Appearance / New event form / Notifications); token- + based icon chips; theme/week-start/language pickers migrated from + `DropdownMenu` to OptionCard dialogs. New `ic_gitea.xml` (Simple Icons, + verbatim path) for the About "Source" button; en+de strings. + - Calendar manager: same collapsing scaffold + grouped rows; shared + `CalendarColorChip` (neutral chip, pastelised calendar glyph). + - Navigation drawer: branded header, grouped View switcher (active view + highlighted via `secondaryContainer`), the filter list restyled to + grouped rows with a trailing checkbox; the whole drawer scrolls as one. + - Cards use `surfaceContainerHigh` for readable contrast against `surface`. + - Donate button on the About card deferred (target still TBD). + ## Next -1. Monitor the F-Droid build/publish for the v2.2.0 tag +1. Monitor the F-Droid build/publish for the v2.3.0 tag 2. Decide the "Locations & People" and "remote calendar create/edit" go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md` -3. **Settings redesign & restructure** is the agreed high-prio next item - (2026-06-16) — group into M3 cards / sub-screens, and migrate the - theme/week-start/language `DropdownMenu` selectors to the OptionCard modal - default (current dropdowns violate `option-card-modal-style-default`). - Structure + style pass only, no new settings features. -4. **Per-event color** follows — reuses the color picker + palette plumbing +3. **Per-event color** is next — reuses the color picker + palette plumbing from local calendar management; finishes the create/edit theme. -5. Then agenda view (strategic, backs a future widget); jump-to-date and +4. Then agenda view (strategic, backs a future widget); jump-to-date and duplicate event remain cheap follow-ups. Full ranked sequence in `ROADMAP.md` → "Near-term sequence". diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f4f3e5..d18b4cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.3.0] — 2026-06-16 + +### Changed +- Redesigned Settings around the Material 3 grouped-list pattern: a large + title that collapses into the toolbar as you scroll, category cards on the + main screen, and dedicated sub-pages for Appearance, the new-event form, and + Notifications. The theme, week-start and language pickers now use the app's + standard option-card dialogs instead of dropdown menus +- About moved to the top of Settings as a card — app icon, author, and quick + links to the source code and licence — with the version shown plainly at the + foot of the list +- The Calendars screen now uses the same grouped-card layout and collapsing + title, and each calendar shows a soft pastel-tinted calendar glyph rather + than a plain colour swatch +- Redesigned the navigation drawer to match: a branded header, the + Month / Week / Day switch and your calendars as grouped cards (with the + active view highlighted), and the whole drawer now scrolls as one + ## [2.2.0] — 2026-06-16 ### Added diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 681306b..be99daa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH // (e.g. v2.0.0 -> 20000). These committed values are the dev/local // default; keep them matching the latest released tag. See docs/RELEASING.md. - versionCode = 20200 - versionName = "2.2.0" + versionCode = 20300 + versionName = "2.3.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 0e2b586..2c49d50 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -40,8 +40,6 @@ import androidx.compose.material.icons.filled.Palette import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -70,14 +68,18 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold +import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.InlineTextField +import de.jeanlucmakiola.calendula.ui.common.Position import de.jeanlucmakiola.calendula.ui.common.pastelize +import de.jeanlucmakiola.calendula.ui.common.positionOf /** Sentinel [editorId] meaning "the editor is composing a new calendar". */ private const val NEW_CALENDAR_ID = Long.MIN_VALUE @@ -139,7 +141,6 @@ fun CalendarsScreen( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun CalendarsList( local: List, @@ -150,11 +151,10 @@ private fun CalendarsList( onAdd: () -> Unit, onEdit: (CalendarSource) -> Unit, ) { + val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val writeErrorText = stringResource(R.string.calendars_write_error) - val dark = isSystemInDarkTheme() - BackHandler(onBack = onBack) LaunchedEffect(error) { if (error) { snackbarHostState.showSnackbar(writeErrorText) @@ -162,79 +162,69 @@ private fun CalendarsList( } } - Scaffold( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.calendars_title)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.settings_back), - ) - } - }, - ) - }, + CollapsingScaffold( + title = stringResource(R.string.calendars_title), + onBack = onBack, snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) { - SectionHeader(stringResource(R.string.calendars_local_header)) - if (local.isEmpty()) { - HintText(stringResource(R.string.calendars_local_empty)) - } else { - local.forEach { calendar -> - CalendarRow( - name = calendar.displayName, - color = calendar.color, - dark = dark, - subtitle = calendar.description, - onClick = { onEdit(calendar) }, - trailing = { - Icon( - Icons.Default.Edit, - contentDescription = stringResource(R.string.calendars_edit_title), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - ) - } - } - FilledTonalButton( - onClick = onAdd, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - ) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.calendars_add)) - } - - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - - SectionHeader(stringResource(R.string.calendars_synced_header)) - HintText(stringResource(R.string.calendars_synced_hint)) - synced - .groupBy { it.accountName.ifBlank { it.accountType } } - .forEach { (account, cals) -> - SyncedAccountGroup( - account = account, - accountType = cals.first().accountType, - calendars = cals, - dark = dark, - ) - } - AddAccountButton() - Spacer(Modifier.height(24.dp)) + ) { + // Local (device-only) calendars — the calendars the app owns. The + // "Add calendar" entry closes the group as its final row. + SectionHeader(stringResource(R.string.calendars_local_header)) + if (local.isEmpty()) { + HintText(stringResource(R.string.calendars_local_empty)) } + val localCount = local.size + 1 + local.forEachIndexed { index, calendar -> + GroupedRow( + title = calendar.displayName, + summary = calendar.description, + position = positionOf(index, localCount), + leading = { CalendarColorChip(calendar.color) }, + trailing = { + Icon( + Icons.Default.Edit, + contentDescription = stringResource(R.string.calendars_edit_title), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + onClick = { onEdit(calendar) }, + ) + } + GroupedRow( + title = stringResource(R.string.calendars_add), + position = positionOf(local.size, localCount), + leading = { AddAvatar() }, + onClick = onAdd, + ) + + Spacer(Modifier.height(16.dp)) + + // Synced calendars — read-only, grouped by account, each with a + // per-account "manage in source app" link. + SectionHeader(stringResource(R.string.calendars_synced_header)) + HintText(stringResource(R.string.calendars_synced_hint)) + synced + .groupBy { it.accountName.ifBlank { it.accountType } } + .forEach { (account, cals) -> + AccountHeader(account = account, accountType = cals.first().accountType) + cals.forEachIndexed { index, calendar -> + GroupedRow( + title = calendar.displayName, + position = positionOf(index, cals.size), + leading = { CalendarColorChip(calendar.color) }, + ) + } + } + Spacer(Modifier.height(8.dp)) + GroupedRow( + title = stringResource(R.string.calendars_add_account), + position = Position.Alone, + leading = { AddAvatar() }, + onClick = { + runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) } + }, + ) } } @@ -449,17 +439,12 @@ private fun ColorPalette(selected: Int, onSelect: (Int) -> Unit, dark: Boolean) } @Composable -private fun SyncedAccountGroup( - account: String, - accountType: String, - calendars: List, - dark: Boolean, -) { +private fun AccountHeader(account: String, accountType: String) { val context = LocalContext.current Row( modifier = Modifier .fillMaxWidth() - .padding(start = 24.dp, end = 16.dp, top = 12.dp, bottom = 4.dp), + .padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -476,65 +461,24 @@ private fun SyncedAccountGroup( Text(stringResource(R.string.calendars_manage_in_app)) } } - calendars.forEach { calendar -> - CalendarRow(name = calendar.displayName, color = calendar.color, dark = dark) - } } +/** Neutral circular chip with a "+" — the leading icon for add-actions. */ @Composable -private fun AddAccountButton() { - val context = LocalContext.current - FilledTonalButton( - onClick = { - runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) } - }, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - ) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.calendars_add_account)) - } -} - -@Composable -private fun CalendarRow( - name: String, - color: Int, - dark: Boolean, - subtitle: String? = null, - onClick: (() -> Unit)? = null, - trailing: @Composable (() -> Unit)? = null, -) { - Row( +private fun AddAvatar() { + Box( modifier = Modifier - .fillMaxWidth() - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - .padding(horizontal = 24.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, ) { - Box( - modifier = Modifier - .size(16.dp) - .clip(CircleShape) - .background(pastelize(color, dark)), + Icon( + Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), ) - Spacer(Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(text = name, style = MaterialTheme.typography.bodyLarge) - if (!subtitle.isNullOrBlank()) { - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } - if (trailing != null) { - Spacer(Modifier.width(8.dp)) - trailing() - } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt index 14b7deb..5360124 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt @@ -1,6 +1,20 @@ package de.jeanlucmakiola.calendula.ui.common +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp /** * Soften a raw calendar color toward a pastel that fits the active theme. @@ -15,3 +29,27 @@ fun pastelize(rawArgb: Int, dark: Boolean): Color { hsv[2] = if (dark) 0.82f else 0.72f return Color(android.graphics.Color.HSVToColor(hsv)) } + +/** + * Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted + * in the calendar's (pastelised) colour. Shared by the calendar manager and the + * visibility filter so they read identically. + */ +@Composable +fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) { + val dark = isSystemInDarkTheme() + Box( + modifier = modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.CalendarMonth, + contentDescription = null, + tint = pastelize(color, dark), + modifier = Modifier.size(22.dp), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt index e0f9a9c..8fb6192 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt @@ -1,20 +1,33 @@ package de.jeanlucmakiola.calendula.ui.common +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet -import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R @@ -23,17 +36,12 @@ import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList /** * Navigation drawer shared by every top-level calendar screen. * - * Visual language (kept deliberately small so sizes don't drift): - * - Drawer title — `titleLarge` - * - Section headers (e.g. "Calendars") — `titleSmall`, primary, text only - * - Nav items (the views, Today / Settings) — Material `NavigationDrawerItem` - * (`labelLarge` label + a single 24dp leading icon) - * - * The "View" section mirrors the top-bar switcher pill: tapping a view here - * selects it (and closes the drawer) rather than cycling. Also hosts the - * per-calendar visibility filter (M3) inline — the calendar list with its - * checkboxes lives here rather than in a separate sheet — plus a Settings - * entry (M4). The host screen owns the drawer state. + * Uses the app's grouped-card design system (see [GroupedRow]): a branded + * header, the View switcher as a grouped card (the active view highlighted), + * the per-calendar visibility filter (M3) inline, and a pinned Settings row. + * The "View" section mirrors the top-bar switcher pill — tapping a view here + * selects it (and closes the drawer) rather than cycling. The host screen owns + * the drawer state. */ @Composable fun CalendarDrawer( @@ -42,46 +50,75 @@ fun CalendarDrawer( onSettings: () -> Unit, ) { ModalDrawerSheet { - Column(Modifier.fillMaxHeight()) { - Text( - text = stringResource(R.string.app_name), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp), - ) - HorizontalDivider() + // The whole sidebar scrolls as one — header, views, the calendar filter + // and Settings all flow in a single scroll container. + Column( + Modifier + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + ) { + DrawerHeader() DrawerSectionHeader(stringResource(R.string.view_section)) - IMPLEMENTED_VIEWS.forEach { view -> - NavigationDrawerItem( - icon = { Icon(view.icon, contentDescription = null) }, - label = { Text(stringResource(view.labelRes)) }, + IMPLEMENTED_VIEWS.forEachIndexed { index, view -> + GroupedRow( + title = stringResource(view.labelRes), + position = positionOf(index, IMPLEMENTED_VIEWS.size), selected = view == currentView, + minHeight = 56.dp, + leading = { Icon(view.icon, contentDescription = null) }, onClick = { onSelectView(view) }, - modifier = Modifier.padding(horizontal = 12.dp), ) } - Spacer(Modifier.height(8.dp)) - HorizontalDivider() - // Calendars (M3) — visibility checkboxes, scrollable, takes the slack - // between the top actions and the pinned Settings entry. + Spacer(Modifier.height(16.dp)) + DrawerSectionHeader(stringResource(R.string.filter_title)) - CalendarFilterList(modifier = Modifier.weight(1f)) + CalendarFilterList() - HorizontalDivider() - Spacer(Modifier.height(8.dp)) - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Settings, contentDescription = null) }, - label = { Text(stringResource(R.string.month_action_settings)) }, - selected = false, + Spacer(Modifier.height(16.dp)) + GroupedRow( + title = stringResource(R.string.month_action_settings), + position = Position.Alone, + minHeight = 56.dp, + leading = { Icon(Icons.Filled.Settings, contentDescription = null) }, onClick = onSettings, - modifier = Modifier.padding(horizontal = 12.dp), ) Spacer(Modifier.height(8.dp)) } } } +/** Branded header: the app-icon chip beside the app name. */ +@Composable +private fun DrawerHeader() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(14.dp)) + .background(colorResource(R.color.ic_launcher_background)), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier.requiredSize(66.dp), + ) + } + Spacer(Modifier.width(16.dp)) + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleLarge, + ) + } +} + /** Top-level grouping label in the drawer. Text only, so it never reads as a * tappable nav item. */ @Composable diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt new file mode 100644 index 0000000..f81afaf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt @@ -0,0 +1,191 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R + +/** + * Position of a row within a grouped list, after the Android-15 settings + * pattern: a run of rows shares one rounded container, with full corners at the + * group's outer edges and small corners between, separated by small gaps. + */ +enum class Position { Top, Middle, Bottom, Alone } + +/** Maps an index within a group of [count] rows to its [Position]. */ +fun positionOf(index: Int, count: Int): Position = when { + count <= 1 -> Position.Alone + index == 0 -> Position.Top + index == count - 1 -> Position.Bottom + else -> Position.Middle +} + +/** + * The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar] + * whose title shrinks into the bar (next to the back button) as the content + * scrolls. Content is a scrollable column that feeds the toolbar via nested + * scroll. Used by Settings and the calendar manager so they share one shell. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CollapsingScaffold( + title: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, + snackbarHost: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + content: @Composable ColumnScope.() -> Unit, +) { + BackHandler(onBack = onBack) + val scrollBehavior = + TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + Scaffold( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + LargeTopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.settings_back), + ) + } + }, + actions = actions, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.topAppBarColors( + scrolledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + snackbarHost = snackbarHost, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 8.dp, bottom = 24.dp), + content = content, + ) + } +} + +/** + * One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose + * corner radii come from its [position] (so a run of rows reads as a single + * rounded card). Corners round further on press. A null [onClick] makes the + * row non-interactive (e.g. read-only entries). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupedRow( + title: String, + position: Position, + modifier: Modifier = Modifier, + summary: String? = null, + selected: Boolean = false, + minHeight: Dp = 72.dp, + leading: @Composable (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, + onClick: (() -> Unit)? = null, +) { + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner") + val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner") + val shape = when (position) { + Position.Alone -> RoundedCornerShape(full) + Position.Top -> RoundedCornerShape( + topStart = full, topEnd = full, bottomStart = small, bottomEnd = small, + ) + Position.Middle -> RoundedCornerShape(small) + Position.Bottom -> RoundedCornerShape( + topStart = small, topEnd = small, bottomStart = full, bottomEnd = full, + ) + } + val gap = when (position) { + Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp) + Position.Bottom, Position.Alone -> Modifier + } + val itemColors = if (selected) { + ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = MaterialTheme.colorScheme.onSecondaryContainer, + leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + supportingColor = MaterialTheme.colorScheme.onSecondaryContainer, + trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } else { + ListItemDefaults.colors(containerColor = Color.Transparent) + } + val item: @Composable () -> Unit = { + ListItem( + headlineContent = { Text(title) }, + supportingContent = summary?.let { text -> { Text(text) } }, + leadingContent = leading, + trailingContent = trailing, + colors = itemColors, + modifier = Modifier.heightIn(min = minHeight), + ) + } + val base = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .then(gap) + val containerColor = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + if (onClick != null) { + Surface( + onClick = onClick, + color = containerColor, + shape = shape, + interactionSource = interaction, + modifier = base, + ) { item() } + } else { + Surface(color = containerColor, shape = shape, modifier = base) { item() } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt index 4a6bd18..f2d7ccf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt @@ -1,25 +1,17 @@ package de.jeanlucmakiola.calendula.ui.filter import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -28,7 +20,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.FailureReason -import de.jeanlucmakiola.calendula.ui.common.pastelize +import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.GroupedRow +import de.jeanlucmakiola.calendula.ui.common.positionOf /** * Calendar-visibility filter (M3), rendered inline in the navigation drawer. @@ -53,67 +47,44 @@ fun CalendarFilterList( } } +/** + * Renders as a plain (non-scrolling) [Column] so it flows inside the drawer's + * single scroll container — the whole sidebar scrolls as one. Calendar counts + * are small, so a lazy list isn't needed. + */ @Composable private fun FilterList( groups: List, onSetVisible: (Long, Boolean) -> Unit, modifier: Modifier = Modifier, ) { - val dark = isSystemInDarkTheme() - LazyColumn( - modifier = modifier.fillMaxWidth(), - contentPadding = PaddingValues(vertical = 4.dp), - ) { + Column(modifier = modifier.fillMaxWidth()) { groups.forEach { group -> - item(key = "header-${group.account}") { - Text( - text = group.account, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp), - ) - } - items(group.calendars, key = { it.id }) { cal -> - CalendarToggleRow( - row = cal, - dark = dark, - onCheckedChange = { onSetVisible(cal.id, it) }, + Text( + text = group.account, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp), + ) + group.calendars.forEachIndexed { index, cal -> + GroupedRow( + title = cal.displayName, + position = positionOf(index, group.calendars.size), + minHeight = 56.dp, + leading = { CalendarColorChip(cal.color) }, + trailing = { + Checkbox( + checked = cal.visible, + onCheckedChange = { onSetVisible(cal.id, it) }, + ) + }, + onClick = { onSetVisible(cal.id, !cal.visible) }, ) } } } } -@Composable -private fun CalendarToggleRow( - row: CalendarRow, - dark: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 28.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Box( - modifier = Modifier - .size(14.dp) - .background(pastelize(row.color, dark), CircleShape), - ) - Text( - text = row.displayName, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - Checkbox( - checked = row.visible, - onCheckedChange = onCheckedChange, - ) - } -} - @Composable private fun FilterLoading(modifier: Modifier = Modifier) { Column( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 2c29c4d..3483664 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -1,65 +1,97 @@ package de.jeanlucmakiola.calendula.ui.settings import android.Manifest +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build -import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.net.toUri +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Gavel +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold +import de.jeanlucmakiola.calendula.ui.common.GroupedRow +import de.jeanlucmakiola.calendula.ui.common.OptionCard +import de.jeanlucmakiola.calendula.ui.common.Position +import de.jeanlucmakiola.calendula.ui.common.positionOf +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec + +/** The settings sub-screens reached from the hub's category rows. */ +private enum class SettingsSection { Appearance, EventForm, Notifications } /** - * Settings (M4) — appearance (theme, dynamic colour, week start), language, - * and an about section. A full-screen destination; [onBack] pops it. + * Token-based accent for a leading icon chip (container / on-container pair). + * Neutral chips stay grey; accents are drawn from the M3 scheme so they adapt + * to theme, dark mode and dynamic colour. + */ +private enum class ChipAccent { Neutral, Primary, Tertiary } + +/** + * Settings (M4), restructured in v2.3 into a category hub with sub-screens. + * Both the hub and the sub-screens use a collapsing [LargeTopAppBar] and the + * grouped-row card system. Calendars opens the separate manager hoisted in + * [CalendarHost]; Language opens an inline OptionCard dialog; About is a card + * at the top. A full-screen destination; [onBack] pops it. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( onBack: () -> Unit, @@ -68,363 +100,452 @@ fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + var section by rememberSaveable { mutableStateOf(null) } + val slideSpec = rememberCalendarSlideSpec() - // Intercept the system back button/gesture — without this it falls through - // to the activity and closes the app instead of returning to the calendar. - BackHandler { onBack() } - - Scaffold( + Box( modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surface), - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.settings_title)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.settings_back), - ) - } - }, - ) - }, - ) { innerPadding -> + ) { + SettingsHub( + onBack = onBack, + onOpenSection = { section = it }, + onManageCalendars = onManageCalendars, + ) + + AnimatedVisibility( + visible = section == SettingsSection.Appearance, + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + ) { + AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }) + } + AnimatedVisibility( + visible = section == SettingsSection.EventForm, + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + ) { + EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null }) + } + AnimatedVisibility( + visible = section == SettingsSection.Notifications, + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + ) { + NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null }) + } + } +} + +// --------------------------------------------------------------------------- +// Hub +// --------------------------------------------------------------------------- + +@Composable +private fun SettingsHub( + onBack: () -> Unit, + onOpenSection: (SettingsSection) -> Unit, + onManageCalendars: () -> Unit, +) { + CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) { + Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() } + Spacer(Modifier.height(16.dp)) + + GroupedRow( + title = stringResource(R.string.settings_section_appearance), + summary = stringResource(R.string.settings_appearance_subtitle), + position = Position.Top, + leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) }, + onClick = { onOpenSection(SettingsSection.Appearance) }, + ) + GroupedRow( + title = stringResource(R.string.settings_section_event_form), + summary = stringResource(R.string.settings_event_form_subtitle), + position = Position.Middle, + leading = { CategoryIcon(Icons.Default.Tune, ChipAccent.Neutral) }, + onClick = { onOpenSection(SettingsSection.EventForm) }, + ) + GroupedRow( + title = stringResource(R.string.settings_section_notifications), + summary = stringResource(R.string.settings_notifications_subtitle), + position = Position.Middle, + leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, + onClick = { onOpenSection(SettingsSection.Notifications) }, + ) + GroupedRow( + title = stringResource(R.string.settings_section_calendars), + summary = stringResource(R.string.settings_manage_calendars_hint), + position = Position.Middle, + leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) }, + onClick = onManageCalendars, + ) + LanguageRow(position = Position.Bottom) + + AppVersionText() + } +} + +@Composable +private fun LanguageRow(position: Position) { + // 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.current()) } + var showDialog by remember { mutableStateOf(false) } + + GroupedRow( + title = stringResource(R.string.settings_language), + summary = languageLabel(current), + position = position, + leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) }, + onClick = { showDialog = true }, + ) + + if (showDialog) { + OptionPickerDialog( + title = stringResource(R.string.settings_language), + options = LanguagePref.entries, + selected = current, + label = { languageLabel(it) }, + onSelect = { + current = it + AppLanguage.apply(it) + }, + onDismiss = { showDialog = false }, + ) + } +} + +@Composable +private fun AboutCard() { + val context = LocalContext.current + val sourceUrl = stringResource(R.string.about_source_url) + val licenseUrl = stringResource(R.string.about_license_url) + + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(24.dp), + modifier = Modifier.fillMaxWidth(), + ) { Column( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) { - SectionHeader(stringResource(R.string.settings_section_appearance)) - - SettingDropdownRow( - title = stringResource(R.string.settings_theme), - selected = state.themeMode, - options = ThemeMode.entries, - optionLabel = { themeLabel(it) }, - onSelect = viewModel::setThemeMode, - ) - DynamicColorRow( - checked = state.dynamicColor, - enabled = state.dynamicColorAvailable, - onCheckedChange = viewModel::setDynamicColor, - ) - SettingDropdownRow( - title = stringResource(R.string.settings_week_start), - selected = state.weekStart, - options = WeekStartPref.entries, - optionLabel = { weekStartLabel(it) }, - onSelect = viewModel::setWeekStart, - ) - - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - SectionHeader(stringResource(R.string.settings_section_event_form)) - Text( - text = stringResource(R.string.settings_form_fields_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 24.dp), - ) - EventFormField.entries.forEach { field -> - FormFieldRow( - title = stringResource(formFieldLabel(field)), - checked = field in state.defaultFormFields, - onCheckedChange = { viewModel.setFormFieldDefault(field, it) }, - ) - } - - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - SectionHeader(stringResource(R.string.settings_section_notifications)) - RemindersRow( - checked = state.remindersEnabled, - onCheckedChange = viewModel::setRemindersEnabled, - ) - - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - SectionHeader(stringResource(R.string.settings_section_calendars)) - NavigationRow( - title = stringResource(R.string.settings_manage_calendars), - subtitle = stringResource(R.string.settings_manage_calendars_hint), - onClick = onManageCalendars, - ) - - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - SectionHeader(stringResource(R.string.settings_section_language)) - LanguageRow() - - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - SectionHeader(stringResource(R.string.settings_section_about)) - AboutSection() - Spacer(Modifier.height(24.dp)) - } - } -} - -@Composable -private fun LanguageRow() { - // Setting a locale recreates the activity; mirror the choice locally so the - // dropdown updates instantly even before the recreation lands. - var current by remember { mutableStateOf(AppLanguage.current()) } - SettingDropdownRow( - title = stringResource(R.string.settings_language), - selected = current, - options = LanguagePref.entries, - optionLabel = { languageLabel(it) }, - onSelect = { - current = it - AppLanguage.apply(it) - }, - ) -} - -@Composable -private fun SectionHeader(text: String) { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), - ) -} - -@Composable -private fun SettingDropdownRow( - title: String, - selected: T, - options: List, - optionLabel: @Composable (T) -> String, - onSelect: (T) -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - Box { - Row( modifier = Modifier .fillMaxWidth() - .clickable { expanded = true } - .padding(horizontal = 24.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(16.dp), ) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f), - ) - Text( - text = optionLabel(selected), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Icon( - Icons.Default.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - options.forEach { option -> - DropdownMenuItem( - text = { Text(optionLabel(option)) }, - onClick = { - expanded = false - onSelect(option) - }, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + AppLogo() + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = stringResource(R.string.settings_about_author), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { openUrl(context, sourceUrl) }, + contentPadding = PaddingValues(horizontal = 12.dp), + modifier = Modifier.weight(1f), + ) { + Icon( + painter = painterResource(R.drawable.ic_gitea), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.settings_about_source)) + } + OutlinedButton( + onClick = { openUrl(context, licenseUrl) }, + contentPadding = PaddingValues(horizontal = 12.dp), + modifier = Modifier.weight(1f), + ) { + Icon( + Icons.Default.Gavel, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.settings_license)) + } } } } } +/** Plain centred version mark at the foot of the settings list (no card). */ @Composable -private fun DynamicColorRow( - checked: Boolean, - enabled: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - Row( +private fun AppVersionText() { + val context = LocalContext.current + val versionName = remember { + runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull() ?: "—" + } + Text( + text = stringResource(R.string.settings_about_version, versionName), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(vertical = 16.dp), + ) +} + +/** + * The app icon as a rounded chip: the off-white launcher mark over its slate + * background colour, rendered oversized and clipped to fill the chip the way a + * launcher mask would. + */ +@Composable +private fun AppLogo() { + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(20.dp)) + .background(colorResource(R.color.ic_launcher_background)), + contentAlignment = Alignment.Center, ) { - Column(Modifier.weight(1f)) { - Text( - text = stringResource(R.string.settings_dynamic_color), - style = MaterialTheme.typography.bodyLarge, - color = if (enabled) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (!enabled) { - Text( - text = stringResource(R.string.settings_dynamic_color_unavailable), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - enabled = enabled, + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = stringResource(R.string.settings_about_logo_desc), + modifier = Modifier.requiredSize(108.dp), ) } } +// --------------------------------------------------------------------------- +// Sub-screens +// --------------------------------------------------------------------------- + +@Composable +private fun AppearanceScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + var showTheme by remember { mutableStateOf(false) } + var showWeekStart by remember { mutableStateOf(false) } + + CollapsingScaffold( + title = stringResource(R.string.settings_section_appearance), + onBack = onBack, + ) { + GroupedRow( + title = stringResource(R.string.settings_theme), + summary = themeLabel(state.themeMode), + position = Position.Top, + onClick = { showTheme = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_dynamic_color), + summary = if (state.dynamicColorAvailable) { + null + } else { + stringResource(R.string.settings_dynamic_color_unavailable) + }, + position = Position.Middle, + trailing = { + Switch( + checked = state.dynamicColor, + onCheckedChange = viewModel::setDynamicColor, + enabled = state.dynamicColorAvailable, + ) + }, + onClick = if (state.dynamicColorAvailable) { + { viewModel.setDynamicColor(!state.dynamicColor) } + } else { + null + }, + ) + GroupedRow( + title = stringResource(R.string.settings_week_start), + summary = weekStartLabel(state.weekStart), + position = Position.Bottom, + onClick = { showWeekStart = true }, + ) + } + + if (showTheme) { + OptionPickerDialog( + title = stringResource(R.string.settings_theme), + options = ThemeMode.entries, + selected = state.themeMode, + label = { themeLabel(it) }, + onSelect = viewModel::setThemeMode, + onDismiss = { showTheme = false }, + ) + } + if (showWeekStart) { + OptionPickerDialog( + title = stringResource(R.string.settings_week_start), + options = WeekStartPref.entries, + selected = state.weekStart, + label = { weekStartLabel(it) }, + onSelect = viewModel::setWeekStart, + onDismiss = { showWeekStart = false }, + ) + } +} + +@Composable +private fun EventFormScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + CollapsingScaffold( + title = stringResource(R.string.settings_section_event_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), + ) + Spacer(Modifier.height(8.dp)) + val fields = EventFormField.entries + fields.forEachIndexed { index, field -> + val checked = field in state.defaultFormFields + GroupedRow( + title = stringResource(formFieldLabel(field)), + position = positionOf(index, fields.size), + trailing = { + Switch( + checked = checked, + onCheckedChange = { viewModel.setFormFieldDefault(field, it) }, + ) + }, + onClick = { viewModel.setFormFieldDefault(field, !checked) }, + ) + } + } +} + /** * Reminder-notifications toggle (v1.4), mirroring the onboarding step. * Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) — * the pref is set either way; the OS permission is the real gate. */ @Composable -private fun RemindersRow( - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, +private fun NotificationsScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, ) { val context = LocalContext.current val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { /* The pref is already on; a denial just leaves the OS gate shut. */ } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text( - text = stringResource(R.string.settings_reminders), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = stringResource(R.string.settings_reminders_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + val toggleReminders: (Boolean) -> Unit = { enabled -> + viewModel.setRemindersEnabled(enabled) + val needsPermission = enabled && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + context, Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + if (needsPermission) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) } - Spacer(Modifier.width(16.dp)) - Switch( - checked = checked, - onCheckedChange = { enabled -> - onCheckedChange(enabled) - val needsPermission = enabled && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - ContextCompat.checkSelfPermission( - context, Manifest.permission.POST_NOTIFICATIONS, - ) != PackageManager.PERMISSION_GRANTED - if (needsPermission) { - launcher.launch(Manifest.permission.POST_NOTIFICATIONS) - } + } + + CollapsingScaffold( + title = stringResource(R.string.settings_section_notifications), + onBack = onBack, + ) { + GroupedRow( + title = stringResource(R.string.settings_reminders), + summary = stringResource(R.string.settings_reminders_hint), + position = Position.Alone, + trailing = { + Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders) }, + onClick = { toggleReminders(!state.remindersEnabled) }, ) } } -@Composable -private fun AboutSection() { - val context = LocalContext.current - val versionName = remember { - runCatching { - context.packageManager.getPackageInfo(context.packageName, 0).versionName - }.getOrNull() ?: "—" - } - val sourceUrl = stringResource(R.string.about_source_url) - - AboutRow( - title = stringResource(R.string.settings_version), - value = versionName, - ) - AboutRow( - title = stringResource(R.string.settings_license), - value = stringResource(R.string.settings_license_value), - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f).padding(start = 8.dp)) { - Text( - text = stringResource(R.string.settings_source), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = sourceUrl.removePrefix("https://"), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - TextButton(onClick = { - val intent = Intent(Intent.ACTION_VIEW, sourceUrl.toUri()) - runCatching { context.startActivity(intent) } - }) { - Text(stringResource(R.string.settings_source_open)) - } - } -} +// --------------------------------------------------------------------------- +// Shared building blocks +// --------------------------------------------------------------------------- +/** + * Leading circular icon chip. Colours come from the M3 scheme via a container / + * on-container token pair, so each accent stays correctly paired across theme, + * dark mode and dynamic colour. + */ @Composable -private fun AboutRow(title: String, value: String) { - Row( +private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) { + val scheme = MaterialTheme.colorScheme + val (background, iconColor) = when (accent) { + ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant + ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer + ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer + } + Box( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, + .size(40.dp) + .clip(CircleShape) + .background(background), + contentAlignment = Alignment.Center, ) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f), + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(22.dp), ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(8.dp)) } } +/** OptionCard selection dialog — the app's only sanctioned picker style. */ @Composable -private fun NavigationRow(title: String, subtitle: String, onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 24.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text(text = title, style = MaterialTheme.typography.bodyLarge) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun FormFieldRow( +private fun OptionPickerDialog( title: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, + options: List, + selected: T, + label: @Composable (T) -> String, + onSelect: (T) -> Unit, + onDismiss: () -> Unit, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f), - ) - Switch(checked = checked, onCheckedChange = onCheckedChange) - } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + options.forEach { option -> + OptionCard( + label = label(option), + onClick = { + onSelect(option) + onDismiss() + }, + selected = option == selected, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +private fun openUrl(context: Context, url: String) { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + runCatching { context.startActivity(intent) } } private fun formFieldLabel(field: EventFormField): Int = when (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-de/strings.xml b/app/src/main/res/values-de/strings.xml index c21fa89..b31a500 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -219,12 +219,17 @@ Systemstandard Deutsch English + + Design, dynamische Farben, Wochenstart + Standardfelder für neue Termine + Termin-Erinnerungen Über - Version Lizenz MIT - Quellcode - Öffnen + von Jean-Luc Makiola + Quellcode + Version %1$s + Calendula-App-Symbol Kalender diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f67934..7319365 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -220,12 +220,17 @@ System default Deutsch English + + Theme, dynamic colour, week start + Default fields for new events + Event reminders About - Version License MIT - Source code - Open + by Jean-Luc Makiola + Source + Version %1$s + Calendula app icon Calendars @@ -245,4 +250,5 @@ \"%1$s\" and all of its events will be permanently removed from this device. Couldn\'t save the change. https://gitea.jeanlucmakiola.de/makiolaj/calendula + https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE