release: cut v2.3.0 — Material 3 grouped-list redesign of Settings, calendars & drawer
All checks were successful
CI / ci (push) Successful in 8m2s
Release — F-Droid repo + Gitea release / ci (push) Successful in 2m1s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 8m44s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 8s

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:
2026-06-16 11:44:10 +02:00
parent e194da3766
commit 210ddff8d8
13 changed files with 960 additions and 577 deletions

View File

@@ -126,6 +126,23 @@ Deliberately deferred (add only if needed):
- Shared `InlineTextField` extracted to `ui.common` (event form + calendar - Shared `InlineTextField` extracted to `ui.common` (event form + calendar
editor share one input style) 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) # 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)** **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 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)* 2. Local calendar management + "manage in source app" deep-links *(shipped v2.2.0)*
3. **Settings redesign & restructure** *(next, high prio)* — see scope below 3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full
4. Per-event color — reuses the calendar color picker/palette; closes the create/edit theme 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 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.) (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 The settings screen has grown into a flat vertical scroll of divider-separated
sections (Appearance, Event form, Notifications, Calendars, Language, About) and sections (Appearance, Event form, Notifications, Calendars, Language, About) and

View File

@@ -7,8 +7,9 @@
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11; **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. 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 **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) calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
shipped 2026-06-16. The backlog is now organised by theme in `ROADMAP.md`. the calendar manager and the navigation drawer) both shipped 2026-06-16. The
backlog is now organised by theme in `ROADMAP.md`.
## Progress ## 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 deep-link (resolved from the account's authenticator: DAVx5/ICSx5/…) and
an add-account shortcut. Shared `InlineTextField` extracted to `ui.common` 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 ## 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" 2. Decide the "Locations & People" and "remote calendar create/edit"
go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md` go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md`
3. **Settings redesign & restructure** is the agreed high-prio next item 3. **Per-event color** is next — reuses the color picker + palette plumbing
(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
from local calendar management; finishes the create/edit theme. 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 duplicate event remain cheap follow-ups. Full ranked sequence in
`ROADMAP.md` → "Near-term sequence". `ROADMAP.md` → "Near-term sequence".

View File

@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [2.2.0] — 2026-06-16
### Added ### Added

View File

@@ -28,8 +28,8 @@ android {
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH // the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local // (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. // default; keep them matching the latest released tag. See docs/RELEASING.md.
versionCode = 20200 versionCode = 20300
versionName = "2.2.0" versionName = "2.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -40,8 +40,6 @@ import androidx.compose.material.icons.filled.Palette
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource 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.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */ /** Sentinel [editorId] meaning "the editor is composing a new calendar". */
private const val NEW_CALENDAR_ID = Long.MIN_VALUE private const val NEW_CALENDAR_ID = Long.MIN_VALUE
@@ -139,7 +141,6 @@ fun CalendarsScreen(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun CalendarsList( private fun CalendarsList(
local: List<CalendarSource>, local: List<CalendarSource>,
@@ -150,11 +151,10 @@ private fun CalendarsList(
onAdd: () -> Unit, onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit, onEdit: (CalendarSource) -> Unit,
) { ) {
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val writeErrorText = stringResource(R.string.calendars_write_error) val writeErrorText = stringResource(R.string.calendars_write_error)
val dark = isSystemInDarkTheme()
BackHandler(onBack = onBack)
LaunchedEffect(error) { LaunchedEffect(error) {
if (error) { if (error) {
snackbarHostState.showSnackbar(writeErrorText) snackbarHostState.showSnackbar(writeErrorText)
@@ -162,42 +162,24 @@ private fun CalendarsList(
} }
} }
Scaffold( CollapsingScaffold(
modifier = Modifier title = stringResource(R.string.calendars_title),
.fillMaxSize() onBack = onBack,
.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),
)
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }, 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)) SectionHeader(stringResource(R.string.calendars_local_header))
if (local.isEmpty()) { if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty)) HintText(stringResource(R.string.calendars_local_empty))
} else { }
local.forEach { calendar -> val localCount = local.size + 1
CalendarRow( local.forEachIndexed { index, calendar ->
name = calendar.displayName, GroupedRow(
color = calendar.color, title = calendar.displayName,
dark = dark, summary = calendar.description,
subtitle = calendar.description, position = positionOf(index, localCount),
onClick = { onEdit(calendar) }, leading = { CalendarColorChip(calendar.color) },
trailing = { trailing = {
Icon( Icon(
Icons.Default.Edit, Icons.Default.Edit,
@@ -206,35 +188,43 @@ private fun CalendarsList(
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
}, },
onClick = { onEdit(calendar) },
) )
} }
} GroupedRow(
FilledTonalButton( title = stringResource(R.string.calendars_add),
position = positionOf(local.size, localCount),
leading = { AddAvatar() },
onClick = onAdd, 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)) SectionHeader(stringResource(R.string.calendars_synced_header))
HintText(stringResource(R.string.calendars_synced_hint)) HintText(stringResource(R.string.calendars_synced_hint))
synced synced
.groupBy { it.accountName.ifBlank { it.accountType } } .groupBy { it.accountName.ifBlank { it.accountType } }
.forEach { (account, cals) -> .forEach { (account, cals) ->
SyncedAccountGroup( AccountHeader(account = account, accountType = cals.first().accountType)
account = account, cals.forEachIndexed { index, calendar ->
accountType = cals.first().accountType, GroupedRow(
calendars = cals, title = calendar.displayName,
dark = dark, 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 @Composable
private fun SyncedAccountGroup( private fun AccountHeader(account: String, accountType: String) {
account: String,
accountType: String,
calendars: List<CalendarSource>,
dark: Boolean,
) {
val context = LocalContext.current val context = LocalContext.current
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
@@ -476,65 +461,24 @@ private fun SyncedAccountGroup(
Text(stringResource(R.string.calendars_manage_in_app)) 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 @Composable
private fun AddAccountButton() { private fun AddAvatar() {
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,
) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(16.dp) .size(40.dp)
.clip(CircleShape) .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()
}
} }
} }

View File

@@ -1,6 +1,20 @@
package de.jeanlucmakiola.calendula.ui.common 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.graphics.Color
import androidx.compose.ui.unit.dp
/** /**
* Soften a raw calendar color toward a pastel that fits the active theme. * 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 hsv[2] = if (dark) 0.82f else 0.72f
return Color(android.graphics.Color.HSVToColor(hsv)) 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),
)
}
}

View File

@@ -1,20 +1,33 @@
package de.jeanlucmakiola.calendula.ui.common 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R 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. * Navigation drawer shared by every top-level calendar screen.
* *
* Visual language (kept deliberately small so sizes don't drift): * Uses the app's grouped-card design system (see [GroupedRow]): a branded
* - Drawer title — `titleLarge` * header, the View switcher as a grouped card (the active view highlighted),
* - Section headers (e.g. "Calendars") — `titleSmall`, primary, text only * the per-calendar visibility filter (M3) inline, and a pinned Settings row.
* - Nav items (the views, Today / Settings) — Material `NavigationDrawerItem` * The "View" section mirrors the top-bar switcher pill — tapping a view here
* (`labelLarge` label + a single 24dp leading icon) * selects it (and closes the drawer) rather than cycling. The host screen owns
* * the drawer state.
* 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.
*/ */
@Composable @Composable
fun CalendarDrawer( fun CalendarDrawer(
@@ -42,43 +50,72 @@ fun CalendarDrawer(
onSettings: () -> Unit, onSettings: () -> Unit,
) { ) {
ModalDrawerSheet { 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(
text = stringResource(R.string.app_name), text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge, 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))
}
} }
} }

View File

@@ -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() }
}
}

View File

@@ -1,25 +1,17 @@
package de.jeanlucmakiola.calendula.ui.filter package de.jeanlucmakiola.calendula.ui.filter
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Checkbox
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -28,7 +20,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason 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. * Calendar-visibility filter (M3), rendered inline in the navigation drawer.
@@ -53,66 +47,43 @@ 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 @Composable
private fun FilterList( private fun FilterList(
groups: List<AccountGroup>, groups: List<AccountGroup>,
onSetVisible: (Long, Boolean) -> Unit, onSetVisible: (Long, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val dark = isSystemInDarkTheme() Column(modifier = modifier.fillMaxWidth()) {
LazyColumn(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(vertical = 4.dp),
) {
groups.forEach { group -> groups.forEach { group ->
item(key = "header-${group.account}") {
Text( Text(
text = group.account, text = group.account,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp), modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
) )
} group.calendars.forEachIndexed { index, cal ->
items(group.calendars, key = { it.id }) { cal -> GroupedRow(
CalendarToggleRow( title = cal.displayName,
row = cal, position = positionOf(index, group.calendars.size),
dark = dark, minHeight = 56.dp,
leading = { CalendarColorChip(cal.color) },
trailing = {
Checkbox(
checked = cal.visible,
onCheckedChange = { onSetVisible(cal.id, it) }, 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 @Composable
private fun FilterLoading(modifier: Modifier = Modifier) { private fun FilterLoading(modifier: Modifier = Modifier) {

View File

@@ -1,65 +1,97 @@
package de.jeanlucmakiola.calendula.ui.settings package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest import android.Manifest
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material3.DropdownMenu import androidx.compose.material.icons.filled.Language
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material.icons.filled.Palette
import androidx.compose.material3.HorizontalDivider import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme 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.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField 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, * Token-based accent for a leading icon chip (container / on-container pair).
* and an about section. A full-screen destination; [onBack] pops it. * 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 @Composable
fun SettingsScreen( fun SettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
@@ -68,206 +100,337 @@ fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() 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 Box(
// to the activity and closes the app instead of returning to the calendar.
BackHandler { onBack() }
Scaffold(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface), .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)) SettingsHub(
onBack = onBack,
SettingDropdownRow( onOpenSection = { section = it },
title = stringResource(R.string.settings_theme), onManageCalendars = onManageCalendars,
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)) AnimatedVisibility(
SectionHeader(stringResource(R.string.settings_section_event_form)) visible = section == SettingsSection.Appearance,
Text( enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
text = stringResource(R.string.settings_form_fields_hint), exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
style = MaterialTheme.typography.bodySmall, ) {
color = MaterialTheme.colorScheme.onSurfaceVariant, AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
modifier = Modifier.padding(horizontal = 24.dp), }
) AnimatedVisibility(
EventFormField.entries.forEach { field -> visible = section == SettingsSection.EventForm,
FormFieldRow( enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
title = stringResource(formFieldLabel(field)), exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
checked = field in state.defaultFormFields, ) {
onCheckedChange = { viewModel.setFormFieldDefault(field, it) }, 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 })
}
}
} }
HorizontalDivider(Modifier.padding(vertical = 8.dp)) // ---------------------------------------------------------------------------
SectionHeader(stringResource(R.string.settings_section_notifications)) // Hub
RemindersRow( // ---------------------------------------------------------------------------
checked = state.remindersEnabled,
onCheckedChange = viewModel::setRemindersEnabled,
)
HorizontalDivider(Modifier.padding(vertical = 8.dp)) @Composable
SectionHeader(stringResource(R.string.settings_section_calendars)) private fun SettingsHub(
NavigationRow( onBack: () -> Unit,
title = stringResource(R.string.settings_manage_calendars), onOpenSection: (SettingsSection) -> Unit,
subtitle = stringResource(R.string.settings_manage_calendars_hint), 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, onClick = onManageCalendars,
) )
LanguageRow(position = Position.Bottom)
HorizontalDivider(Modifier.padding(vertical = 8.dp)) AppVersionText()
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 @Composable
private fun LanguageRow() { private fun LanguageRow(position: Position) {
// Setting a locale recreates the activity; mirror the choice locally so the // 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()) } 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), title = stringResource(R.string.settings_language),
selected = current,
options = LanguagePref.entries, options = LanguagePref.entries,
optionLabel = { languageLabel(it) }, selected = current,
label = { languageLabel(it) },
onSelect = { onSelect = {
current = it current = it
AppLanguage.apply(it) AppLanguage.apply(it)
}, },
onDismiss = { showDialog = false },
) )
} }
}
@Composable @Composable
private fun SectionHeader(text: String) { private fun AboutCard() {
Text( val context = LocalContext.current
text = text, val sourceUrl = stringResource(R.string.about_source_url)
style = MaterialTheme.typography.labelLarge, val licenseUrl = stringResource(R.string.about_license_url)
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
}
@Composable Surface(
private fun <T> SettingDropdownRow( color = MaterialTheme.colorScheme.surfaceContainerHigh,
title: String, shape = RoundedCornerShape(24.dp),
selected: T, modifier = Modifier.fillMaxWidth(),
options: List<T>,
optionLabel: @Composable (T) -> String,
onSelect: (T) -> Unit,
) { ) {
var expanded by remember { mutableStateOf(false) } Column(
Box {
Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { expanded = true } .padding(16.dp),
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) {
AppLogo()
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text( Text(
text = title, text = stringResource(R.string.app_name),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f),
) )
Text( Text(
text = optionLabel(selected), text = stringResource(R.string.settings_about_author),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { }
options.forEach { option -> Spacer(Modifier.height(12.dp))
DropdownMenuItem( Row(
text = { Text(optionLabel(option)) }, modifier = Modifier.fillMaxWidth(),
onClick = { horizontalArrangement = Arrangement.spacedBy(8.dp),
expanded = false ) {
onSelect(option) 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 @Composable
private fun DynamicColorRow( private fun AppVersionText() {
checked: Boolean, val context = LocalContext.current
enabled: Boolean, val versionName = remember {
onCheckedChange: (Boolean) -> Unit, runCatching {
) { context.packageManager.getPackageInfo(context.packageName, 0).versionName
Row( }.getOrNull() ?: ""
modifier = Modifier }
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text( Text(
text = stringResource(R.string.settings_dynamic_color), text = stringResource(R.string.settings_about_version, versionName),
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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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( Switch(
checked = checked, checked = checked,
onCheckedChange = onCheckedChange, onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
enabled = enabled,
) )
},
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. * the pref is set either way; the OS permission is the real gate.
*/ */
@Composable @Composable
private fun RemindersRow( private fun NotificationsScreen(
checked: Boolean, state: SettingsUiState,
onCheckedChange: (Boolean) -> Unit, viewModel: SettingsViewModel,
onBack: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),
) { /* The pref is already on; a denial just leaves the OS gate shut. */ } ) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
Row( val toggleReminders: (Boolean) -> Unit = { enabled ->
modifier = Modifier viewModel.setRemindersEnabled(enabled)
.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 needsPermission = enabled && val needsPermission = enabled &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
@@ -315,116 +459,93 @@ private fun RemindersRow(
if (needsPermission) { if (needsPermission) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS) 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 openUrl(context: Context, url: String) {
private fun AboutSection() { val intent = Intent(Intent.ACTION_VIEW, url.toUri())
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) } 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) { private fun formFieldLabel(field: EventFormField): Int = when (field) {

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Gitea brand mark, used on the "Source" button in Settings → About.
Single-path logo from Simple Icons (https://simpleicons.org, CC0),
pathData kept verbatim so Android's PathParser reads the arc flags.
fillColor is a placeholder; the Compose Icon recolours it via tint.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z" />
</vector>

View File

@@ -219,12 +219,17 @@
<string name="settings_language_auto">Systemstandard</string> <string name="settings_language_auto">Systemstandard</string>
<string name="settings_language_german">Deutsch</string> <string name="settings_language_german">Deutsch</string>
<string name="settings_language_english">English</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_section_about">Über</string>
<string name="settings_version">Version</string>
<string name="settings_license">Lizenz</string> <string name="settings_license">Lizenz</string>
<string name="settings_license_value">MIT</string> <string name="settings_license_value">MIT</string>
<string name="settings_source">Quellcode</string> <string name="settings_about_author">von Jean-Luc Makiola</string>
<string name="settings_source_open">Öffnen</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 --> <!-- Calendar manager -->
<string name="calendars_title">Kalender</string> <string name="calendars_title">Kalender</string>

View File

@@ -220,12 +220,17 @@
<string name="settings_language_auto">System default</string> <string name="settings_language_auto">System default</string>
<string name="settings_language_german">Deutsch</string> <string name="settings_language_german">Deutsch</string>
<string name="settings_language_english">English</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_section_about">About</string>
<string name="settings_version">Version</string>
<string name="settings_license">License</string> <string name="settings_license">License</string>
<string name="settings_license_value">MIT</string> <string name="settings_license_value">MIT</string>
<string name="settings_source">Source code</string> <string name="settings_about_author">by Jean-Luc Makiola</string>
<string name="settings_source_open">Open</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 --> <!-- Calendar manager -->
<string name="calendars_title">Calendars</string> <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_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="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_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> </resources>