release: cut v2.3.0 — Material 3 grouped-list redesign of Settings, calendars & drawer
All checks were successful
All checks were successful
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
18
CHANGELOG.md
18
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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<CalendarSource>,
|
||||
@@ -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,42 +162,24 @@ 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()),
|
||||
) {
|
||||
// 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))
|
||||
} else {
|
||||
local.forEach { calendar ->
|
||||
CalendarRow(
|
||||
name = calendar.displayName,
|
||||
color = calendar.color,
|
||||
dark = dark,
|
||||
subtitle = calendar.description,
|
||||
onClick = { onEdit(calendar) },
|
||||
}
|
||||
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,
|
||||
@@ -206,35 +188,43 @@ private fun CalendarsList(
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
onClick = { onEdit(calendar) },
|
||||
)
|
||||
}
|
||||
}
|
||||
FilledTonalButton(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_add),
|
||||
position = positionOf(local.size, localCount),
|
||||
leading = { AddAvatar() },
|
||||
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))
|
||||
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) ->
|
||||
SyncedAccountGroup(
|
||||
account = account,
|
||||
accountType = cals.first().accountType,
|
||||
calendars = cals,
|
||||
dark = dark,
|
||||
AccountHeader(account = account, accountType = cals.first().accountType)
|
||||
cals.forEachIndexed { index, calendar ->
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = positionOf(index, cals.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
)
|
||||
}
|
||||
AddAccountButton()
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
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<CalendarSource>,
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
private fun AddAvatar() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(pastelize(color, dark)),
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,43 +50,72 @@ fun CalendarDrawer(
|
||||
onSettings: () -> Unit,
|
||||
) {
|
||||
ModalDrawerSheet {
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
// 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.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) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
DrawerSectionHeader(stringResource(R.string.filter_title))
|
||||
CalendarFilterList()
|
||||
|
||||
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,
|
||||
)
|
||||
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,
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||
IMPLEMENTED_VIEWS.forEach { view ->
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(view.icon, contentDescription = null) },
|
||||
label = { Text(stringResource(view.labelRes)) },
|
||||
selected = view == currentView,
|
||||
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.
|
||||
DrawerSectionHeader(stringResource(R.string.filter_title))
|
||||
CalendarFilterList(modifier = Modifier.weight(1f))
|
||||
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||
label = { Text(stringResource(R.string.month_action_settings)) },
|
||||
selected = false,
|
||||
onClick = onSettings,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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<AccountGroup>,
|
||||
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,
|
||||
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(
|
||||
|
||||
@@ -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,206 +100,337 @@ fun SettingsScreen(
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var section by rememberSaveable { mutableStateOf<SettingsSection?>(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 ->
|
||||
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,
|
||||
SettingsHub(
|
||||
onBack = onBack,
|
||||
onOpenSection = { section = it },
|
||||
onManageCalendars = onManageCalendars,
|
||||
)
|
||||
|
||||
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) },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Appearance,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
|
||||
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))
|
||||
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 LanguageRow() {
|
||||
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
|
||||
// dropdown updates instantly even before the recreation lands.
|
||||
// row updates instantly even before the recreation lands.
|
||||
var current by remember { mutableStateOf(AppLanguage.current()) }
|
||||
SettingDropdownRow(
|
||||
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),
|
||||
selected = current,
|
||||
options = LanguagePref.entries,
|
||||
optionLabel = { languageLabel(it) },
|
||||
selected = current,
|
||||
label = { languageLabel(it) },
|
||||
onSelect = {
|
||||
current = it
|
||||
AppLanguage.apply(it)
|
||||
},
|
||||
onDismiss = { showDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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),
|
||||
)
|
||||
}
|
||||
private fun AboutCard() {
|
||||
val context = LocalContext.current
|
||||
val sourceUrl = stringResource(R.string.about_source_url)
|
||||
val licenseUrl = stringResource(R.string.about_license_url)
|
||||
|
||||
@Composable
|
||||
private fun <T> SettingDropdownRow(
|
||||
title: String,
|
||||
selected: T,
|
||||
options: List<T>,
|
||||
optionLabel: @Composable (T) -> String,
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Row(
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = true }
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AppLogo()
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Text(
|
||||
text = optionLabel(selected),
|
||||
text = stringResource(R.string.settings_about_author),
|
||||
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)
|
||||
},
|
||||
}
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
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_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),
|
||||
text = stringResource(R.string.settings_about_version, versionName),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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,
|
||||
) {
|
||||
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 = onCheckedChange,
|
||||
enabled = enabled,
|
||||
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setFormFieldDefault(field, !checked) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,36 +440,17 @@ private fun DynamicColorRow(
|
||||
* 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,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = { enabled ->
|
||||
onCheckedChange(enabled)
|
||||
val toggleReminders: (Boolean) -> Unit = { enabled ->
|
||||
viewModel.setRemindersEnabled(enabled)
|
||||
val needsPermission = enabled &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(
|
||||
@@ -315,116 +459,93 @@ private fun RemindersRow(
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 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
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = iconColor,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** OptionCard selection dialog — the app's only sanctioned picker style. */
|
||||
@Composable
|
||||
private fun <T> OptionPickerDialog(
|
||||
title: String,
|
||||
options: List<T>,
|
||||
selected: T,
|
||||
label: @Composable (T) -> String,
|
||||
onSelect: (T) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
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)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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())
|
||||
private fun openUrl(context: Context, url: String) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
runCatching { context.startActivity(intent) }
|
||||
}) {
|
||||
Text(stringResource(R.string.settings_source_open))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutRow(title: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
title: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> 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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formFieldLabel(field: EventFormField): Int = when (field) {
|
||||
|
||||
16
app/src/main/res/drawable/ic_gitea.xml
Normal file
16
app/src/main/res/drawable/ic_gitea.xml
Normal 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>
|
||||
@@ -219,12 +219,17 @@
|
||||
<string name="settings_language_auto">Systemstandard</string>
|
||||
<string name="settings_language_german">Deutsch</string>
|
||||
<string name="settings_language_english">English</string>
|
||||
<!-- Hub category subtitles -->
|
||||
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
|
||||
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
||||
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
||||
<string name="settings_section_about">Über</string>
|
||||
<string name="settings_version">Version</string>
|
||||
<string name="settings_license">Lizenz</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_source">Quellcode</string>
|
||||
<string name="settings_source_open">Öffnen</string>
|
||||
<string name="settings_about_author">von Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Quellcode</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
||||
|
||||
<!-- Calendar manager -->
|
||||
<string name="calendars_title">Kalender</string>
|
||||
|
||||
@@ -220,12 +220,17 @@
|
||||
<string name="settings_language_auto">System default</string>
|
||||
<string name="settings_language_german">Deutsch</string>
|
||||
<string name="settings_language_english">English</string>
|
||||
<!-- Hub category subtitles -->
|
||||
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
|
||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||
<string name="settings_section_about">About</string>
|
||||
<string name="settings_version">Version</string>
|
||||
<string name="settings_license">License</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_source">Source code</string>
|
||||
<string name="settings_source_open">Open</string>
|
||||
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Source</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_about_logo_desc">Calendula app icon</string>
|
||||
|
||||
<!-- Calendar manager -->
|
||||
<string name="calendars_title">Calendars</string>
|
||||
@@ -245,4 +250,5 @@
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" and all of its events will be permanently removed from this device.</string>
|
||||
<string name="calendars_write_error">Couldn\'t save the change.</string>
|
||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user