Compare commits

...

13 Commits

Author SHA1 Message Date
f81f593017 fix(edit): curate the CalDAV colour picker (#22)
CalDAV sync adapters (DAVx5) publish all ~147 CSS3 named colours into
CalendarContract.Colors, so the event-colour picker showed a full screen
of alphabetically-scrambled, partly duplicated swatches.

Curation now runs in the space the picker actually paints — every swatch
is softened through pastelize, which pins lightness and caps saturation,
so the raw palette's lightness axis is invisible on screen. Judging
distinctness there: colours that paint identically collapse to one
(folding aliases, dark/light shades of a hue, and the neutrals together),
oversized palettes drop washed-out neutral-origin tints and thin by CIE76
ΔE in painted Lab, and survivors sort continuously by painted hue with the
wheel cut at its single widest gap. The CSS3 dump lands at ~33 distinct,
rainbow-ordered swatches; small hand-picked palettes (Google's) pass
through untouched. Every surviving swatch keeps its provider colour key so
picks still round-trip through sync.

This revives work stranded on fix/caldav-color-picker (never merged) and
adapts it to the floret-kit extraction of pastelize: the curation's
painted-space transform now lives self-contained in domain/pastelArgb as a
mirror of floret's pastelize shaping, rather than the two sharing one
function.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:20:07 +02:00
8ddc11159b Merge pull request 'Translations update from Weblate' (!66) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Renovate / renovate (push) Successful in 1m8s
Reviewed-on: #66
2026-07-12 08:50:03 +00:00
Kachelkaiser
6736bae5ee Translated using Weblate (German)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 10m17s
Currently translated at 100.0% (424 of 424 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-07-10 23:01:07 +00:00
Sam Gamgee
21d1726d7a Translated using Weblate (Italian)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 10m38s
Currently translated at 100.0% (410 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-07 14:42:36 +00:00
1e7ee5b98a Merge pull request 'release: v2.14.0 — Day view on date-header tap' (!61) from release/v2.14.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 5s
Release — F-Droid repo + Gitea release / release (push) Successful in 8m51s
Reviewed-on: #61
2026-07-07 14:42:29 +00:00
a82df3f6d0 Merge branch 'main' into release/v2.14.0
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 5m17s
2026-07-07 14:37:07 +00:00
e0e3eb73b9 chore: pin floret-kit to v0.1.0
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m39s
Move the submodule pin from a loose main commit to the tagged v0.1.0
release (same tree content), so the from-source F-Droid build tracks a
stable, traceable kit version instead of a rolling commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:59:28 +02:00
8a7a0af207 chore(fastlane): refresh screenshots, add it/es store listings
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m36s
Recapture all phone screenshots on the Pixel 10 Pro (Android 16) at a
uniform 1280x2856 across every locale, showing the current UI with sample
events: week, month, day, event detail, agenda, and the calendar-access
onboarding. Replaces the old 05-edit shot with 05-agenda (the light theme
has no standalone edit screen) for en-US and de-DE.

Add fully localized store metadata (title, summary, full description) and
per-locale icon for Italian (it-IT) and Spanish (es-ES), matching the
existing en-US/de-DE listings; the app UI is already translated for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:45:44 +02:00
71a652ce3b feat: full-screen selection pickers (retire OptionCard modals, bar actions)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 10m8s
Unify the app's "choose one" surfaces on the full-screen picker style
(floret-kit FullScreenPicker/OptionPicker) instead of the OptionCard modal
dialogs, for consistency across the app:

- Event editor: visibility + add-field -> OptionPicker; reminder, recurrence
  rule, and colour -> FullScreenPicker, with the custom-value Add / OK and the
  colour Reset carried in the app-bar via the picker's new `actions` slot; the
  save-conflict chooser -> full-screen.
- The recurring scope choosers stay compact OptionCard popups — saving an edit
  to, or deleting, a recurring event — since a quick 2-3 option decision reads
  better as a popup than a near-empty full screen.

Bumps the floret-kit pin (55ad536 -> e1919ca) for the FullScreenPicker
`actions` passthrough.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:23:23 +02:00
5fa6eac1ad Merge chore/floret-implementation into release/v2.14.0
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 8m41s
Brings the floret-kit migration onto the 2.14.0 release line: Calendula now
draws its shared UI/crash/locale/reminder/time code from the floret-kit
submodule (Gradle composite build) instead of inline copies, plus the
week-number isoWeekNumber extraction and the wrapped-title height animation.

Pins floret-kit at 55ad536 (origin/main). Integrated for a full pre-release
verification sweep; on-device review still owed before cutting the release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 11:03:25 +02:00
a8aeae5f32 Merge pull request 'Translations update from Weblate' (!63) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #63
2026-07-06 20:40:51 +00:00
Ulisse Perusin
5771b603f2 Translated using Weblate (Italian)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 10m29s
Currently translated at 99.0% (406 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-06 20:20:20 +00:00
Kachelkaiser
5725989aff Translated using Weblate (German)
Currently translated at 100.0% (410 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-07-06 20:20:19 +00:00
43 changed files with 830 additions and 276 deletions

View File

@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
app (such as DAVx5), the event colour picker showed every colour the account
publishes — nearly 150 swatches in alphabetical order, many of them
duplicates or near-identical shades. The picker now shows only visually
distinct colours, arranged as a rainbow; near-duplicate shades and the
washed-out neutrals are folded away so no two swatches look alike. Picked
colours still sync exactly as before, and calendars with hand-picked
palettes (like Google's) are unaffected. Thanks to @ptab for the report
([#22]).
## [2.14.0] — 2026-07-06
### Added
@@ -865,6 +878,7 @@ automatically, with zero telemetry and no internet permission.
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27

View File

@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
@@ -63,10 +64,12 @@ interface CalendarDataSource {
/**
* The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
* account exposes no palette (most local calendars, some CalDAV) — the
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
* which a synced calendar may drop on its next sync.
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
* thinned to visually distinct swatches when oversized (CalDAV adapters
* publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker].
* Empty when the account exposes no palette (most local calendars, some
* CalDAV) — the signal that a custom colour can only be written as a raw
* `EVENT_COLOR`, which a synced calendar may drop on its next sync.
*/
fun eventColorPalette(calendarId: Long): List<EventColorOption>
@@ -611,7 +614,7 @@ class AndroidCalendarDataSource @Inject constructor(
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
}
?.filter { it.key.isNotEmpty() }
?.sortedBy { it.key }
?.curatedForPicker()
?: emptyList()
}

View File

@@ -0,0 +1,185 @@
package de.jeanlucmakiola.calendula.domain
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cbrt
import kotlin.math.hypot
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sqrt
/**
* Curates an account's published event palette for the colour picker.
*
* Sync adapters differ wildly in what they publish: Google exposes a
* hand-picked two-dozen set, while CalDAV adapters (DAVx5) dump all ~147 CSS3
* named colours — including exact-value aliases (aqua/cyan, the gray/grey
* spelling pairs) and dozens of visually indistinguishable whites and grays
* (#22).
*
* Crucially, curation runs against the colour the picker actually *paints*, not
* the raw provider value. The picker softens every swatch through [pastelArgb]:
* it pins lightness to a constant and caps saturation, so the raw palette's
* lightness axis is invisible on screen. Two raw colours that look different —
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
* grays, white) paints as the same pale tint. Judging distinctness in raw
* space, as before, left near-identical painted swatches and stranded the
* neutrals as a run of look-alike "pinks" at the end of the grid.
*
* Three steps, all in painted space:
* 1. Collapse swatches that paint identically to one (alphabetically-first key
* wins, deterministically) — this folds aliases, dark/light shades of a
* hue, and all the neutrals together.
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
* then thinned to visually distinct colours: most vivid first, a colour is
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
* colour already kept. Small palettes are already curated by their adapter
* and pass through whole.
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
* with the wheel cut at its single widest empty gap so the one unavoidable
* seam lands in dead space and no hue family is torn across both ends.
*
* Every surviving option keeps its provider [EventColorOption.key], so a pick
* still round-trips through sync.
*/
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
val painted = sortedBy { it.key }
.distinctBy { pastelArgb(it.argb) }
.map { it to Lab.of(pastelArgb(it.argb)) }
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
painted
} else {
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
}
return orderAroundWheel(kept).map { (option, _) -> option }
}
/**
* Orders swatches continuously around the (painted) hue wheel, then cuts the
* circle at its widest angular gap so the single seam lands in empty space
* instead of mid-family. Saturation breaks ties, vivid first.
*/
private fun orderAroundWheel(
swatches: List<Pair<EventColorOption, Lab>>,
): List<Pair<EventColorOption, Lab>> {
if (swatches.size < 2) return swatches
val byHue = swatches.sortedWith(
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
)
// Split the wheel after the largest empty arc between neighbouring hues;
// the default is the wrap gap (last hue back round to the first), i.e. the
// familiar 0→360 order, and we only rotate away from it for a wider void.
var cutAfter = byHue.lastIndex
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
for (i in 0 until byHue.lastIndex) {
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
if (gap > widestGap) {
widestGap = gap
cutAfter = i
}
}
return byHue.subList(cutAfter + 1, byHue.size) + byHue.subList(0, cutAfter + 1)
}
/** Greedy max-distance filter: vivid colours stake out clusters first. */
private fun thin(
swatches: List<Pair<EventColorOption, Lab>>,
): List<Pair<EventColorOption, Lab>> {
val byVividness = swatches
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
for (candidate in byVividness) {
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
}
return kept
}
/**
* The softening the colour picker paints over every swatch: keep the hue, scale
* and clamp saturation into a gentle band, and pin value to a constant so
* nothing screams and everything reads on the surface. Value is fixed here so
* curation is theme-independent — only hue and saturation distinguish painted
* swatches.
*
* This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
* shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
* rather than theme-picked. Curation must reason about the colour the picker
* paints, so the two shapings have to agree: if floret's saturation band or
* curve changes, update this in step.
*/
fun pastelArgb(rawArgb: Int): Int {
val r = ((rawArgb shr 16) and 0xFF) / 255f
val g = ((rawArgb shr 8) and 0xFF) / 255f
val b = (rawArgb and 0xFF) / 255f
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val delta = max - min
val hue = when {
delta == 0f -> 0f
max == r -> 60f * (((g - b) / delta) % 6f)
max == g -> 60f * (((b - r) / delta) + 2f)
else -> 60f * (((r - g) / delta) + 4f)
}.let { if (it < 0f) it + 360f else it }
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
val s = sat.coerceIn(0.25f, 0.65f)
val v = PASTEL_VALUE
val c = v * s
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
val m = v - c
val (rr, gg, bb) = when {
hue < 60f -> Triple(c, x, 0f)
hue < 120f -> Triple(x, c, 0f)
hue < 180f -> Triple(0f, c, x)
hue < 240f -> Triple(0f, x, c)
hue < 300f -> Triple(x, 0f, c)
else -> Triple(c, 0f, x)
}
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
}
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
private const val PASTEL_VALUE = 0.82f
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
private const val CURATION_TRIGGER_SIZE = 36
/** Minimum CIE76 ΔE between surviving painted swatches. */
private const val MIN_DELTA_E = 13.0
/**
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
* is dropped rather than shown as pale filler.
*/
private const val PASTEL_CHROMA_FLOOR = 22.0
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
private class Lab(val l: Double, val a: Double, val b: Double) {
val chroma: Double get() = hypot(a, b)
/** Hue angle in degrees, 0360, around the Lab a-b plane. */
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
fun deltaE(other: Lab): Double =
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
companion object {
fun of(argb: Int): Lab {
fun linear(shift: Int): Double {
val c = ((argb shr shift) and 0xFF) / 255.0
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
}
val r = linear(16)
val g = linear(8)
val b = linear(0)
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
val fy = f(y)
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
}
}
}

View File

@@ -134,6 +134,7 @@ import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
@@ -345,29 +346,29 @@ private fun SaveConflictDialog(
onDiscard: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_conflict_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.event_edit_conflict_body))
Spacer(Modifier.height(4.dp))
OptionCard(
label = stringResource(R.string.event_edit_conflict_overwrite),
supportingText = stringResource(R.string.event_edit_conflict_overwrite_hint),
onClick = onOverwrite,
)
OptionCard(
label = stringResource(R.string.event_edit_conflict_discard),
supportingText = stringResource(R.string.event_edit_conflict_discard_hint),
onClick = onDiscard,
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
FullScreenPicker(
title = stringResource(R.string.event_edit_conflict_title),
onDismiss = onDismiss,
) {
Text(
text = stringResource(R.string.event_edit_conflict_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
)
GroupedRow(
title = stringResource(R.string.event_edit_conflict_overwrite),
summary = stringResource(R.string.event_edit_conflict_overwrite_hint),
position = positionOf(0, 2),
onClick = onOverwrite,
)
GroupedRow(
title = stringResource(R.string.event_edit_conflict_discard),
summary = stringResource(R.string.event_edit_conflict_discard_hint),
position = positionOf(1, 2),
onClick = onDiscard,
)
}
}
/**
@@ -1064,24 +1065,19 @@ private fun FieldPickerDialog(
onSelect: (EventFormField) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_more_fields)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
hiddenFields.forEach { field ->
OptionCard(
label = stringResource(eventFormFieldLabel(field)),
onClick = { onSelect(field) },
icon = eventFormFieldIcon(field),
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
FullScreenPicker(
title = stringResource(R.string.event_edit_more_fields),
onDismiss = onDismiss,
) {
hiddenFields.forEachIndexed { index, field ->
GroupedRow(
title = stringResource(eventFormFieldLabel(field)),
position = positionOf(index, hiddenFields.size),
leading = { Icon(imageVector = eventFormFieldIcon(field), contentDescription = null) },
onClick = { onSelect(field) },
)
}
}
}
/** Quick-pick lead times offered as chips in the reminder dialog. */
@@ -1106,41 +1102,10 @@ private fun ReminderPickerDialog(
?.takeIf { it in 1..999 }
?.let { it * unit.minutesFactor }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_add_reminder)) },
text = {
if (!customMode) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
REMINDER_QUICK_PICKS.filterNot { it in alreadyChosen }.forEach { minutes ->
OptionCard(
label = reminderLabel(minutes),
onClick = { onSelect(minutes) },
)
}
OptionCard(
label = stringResource(R.string.event_edit_reminder_custom),
onClick = { customMode = true },
labelColor = MaterialTheme.colorScheme.primary,
)
}
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(
value = amountText,
onValueChange = { amountText = it },
placeholder = "10",
)
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(
label = stringResource(reminderUnitLabel(unit)),
entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
onPick = { unit = ReminderUnit.entries[it] },
)
}
}
},
confirmButton = {
FullScreenPicker(
title = stringResource(R.string.event_edit_add_reminder),
onDismiss = onDismiss,
actions = {
// The quick-pick list adds on tap; only the custom step needs Add.
if (customMode) {
TextButton(
@@ -1149,10 +1114,41 @@ private fun ReminderPickerDialog(
) { Text(stringResource(R.string.event_edit_add)) }
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
) {
if (!customMode) {
val presets = REMINDER_QUICK_PICKS.filterNot { it in alreadyChosen }
val rowCount = presets.size + 1
presets.forEachIndexed { index, minutes ->
GroupedRow(
title = reminderLabel(minutes),
position = positionOf(index, rowCount),
onClick = { onSelect(minutes) },
)
}
GroupedRow(
title = stringResource(R.string.event_edit_reminder_custom),
position = positionOf(rowCount - 1, rowCount),
onClick = { customMode = true },
)
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
) {
DialogAmountField(
value = amountText,
onValueChange = { amountText = it },
placeholder = "10",
)
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(
label = stringResource(reminderUnitLabel(unit)),
entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
onPick = { unit = ReminderUnit.entries[it] },
)
}
}
}
}
/** How a custom recurrence ends; mirrors [RecurrenceEnd] in saveable form. */
@@ -1222,109 +1218,10 @@ private fun RecurrencePickerDialog(
null
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_detail_recurrence)) },
text = {
if (!customMode) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_edit_recurrence_none),
onClick = { onSelect(null) },
selected = current == null,
)
RecurrenceFreq.entries.forEach { entry ->
OptionCard(
label = stringResource(recurrencePresetLabel(entry)),
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
selected = isPlainPreset && parsed?.freq == entry,
)
}
OptionCard(
label = stringResource(R.string.event_edit_recurrence_custom),
onClick = { customMode = true },
selected = current != null && !isPlainPreset,
labelColor = MaterialTheme.colorScheme.primary,
)
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.event_edit_recurrence_every),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(12.dp))
DialogAmountField(
value = intervalText,
onValueChange = { intervalText = it },
placeholder = "1",
)
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(
label = stringResource(recurrenceUnitLabel(freq)),
entries = RecurrenceFreq.entries.map {
stringResource(recurrenceUnitLabel(it))
},
onPick = { freq = RecurrenceFreq.entries[it] },
)
}
if (freq == RecurrenceFreq.Weekly) {
Spacer(Modifier.height(4.dp))
WeekdayToggleRow(
selected = daysMask.toDaySet(),
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
locale = locale,
)
}
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.event_edit_recurrence_ends),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OptionCard(
label = stringResource(R.string.event_edit_recurrence_end_never),
onClick = { endMode = RecurrenceEndMode.Never },
selected = endMode == RecurrenceEndMode.Never,
)
OptionCard(
label = stringResource(R.string.event_edit_recurrence_end_until),
onClick = {
endMode = RecurrenceEndMode.Until
showUntilPicker = true
},
supportingText = untilDate?.let {
remember(it, locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale).format(it.toJavaLocalDate())
}
},
selected = endMode == RecurrenceEndMode.Until,
)
OptionCard(
label = stringResource(R.string.event_edit_recurrence_end_count),
onClick = { endMode = RecurrenceEndMode.Count },
selected = endMode == RecurrenceEndMode.Count,
)
if (endMode == RecurrenceEndMode.Count) {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(
value = countText,
onValueChange = { countText = it },
placeholder = "10",
)
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.event_edit_recurrence_times),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
}
},
confirmButton = {
FullScreenPicker(
title = stringResource(R.string.event_detail_recurrence),
onDismiss = onDismiss,
actions = {
// The preset list applies on tap; only the custom step needs OK.
if (customMode) {
TextButton(
@@ -1333,10 +1230,113 @@ private fun RecurrencePickerDialog(
) { Text(stringResource(R.string.dialog_ok)) }
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
) {
if (!customMode) {
val rowCount = RecurrenceFreq.entries.size + 2
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_none),
position = positionOf(0, rowCount),
selected = current == null,
onClick = { onSelect(null) },
)
RecurrenceFreq.entries.forEachIndexed { index, entry ->
GroupedRow(
title = stringResource(recurrencePresetLabel(entry)),
position = positionOf(index + 1, rowCount),
selected = isPlainPreset && parsed?.freq == entry,
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
)
}
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_custom),
position = positionOf(rowCount - 1, rowCount),
selected = current != null && !isPlainPreset,
onClick = { customMode = true },
)
} else {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 24.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.event_edit_recurrence_every),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(12.dp))
DialogAmountField(
value = intervalText,
onValueChange = { intervalText = it },
placeholder = "1",
)
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(
label = stringResource(recurrenceUnitLabel(freq)),
entries = RecurrenceFreq.entries.map {
stringResource(recurrenceUnitLabel(it))
},
onPick = { freq = RecurrenceFreq.entries[it] },
)
}
if (freq == RecurrenceFreq.Weekly) {
WeekdayToggleRow(
selected = daysMask.toDaySet(),
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
locale = locale,
)
}
Text(
text = stringResource(R.string.event_edit_recurrence_ends),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_never),
position = positionOf(0, 3),
selected = endMode == RecurrenceEndMode.Never,
onClick = { endMode = RecurrenceEndMode.Never },
)
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_until),
summary = untilDate?.let {
remember(it, locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale).format(it.toJavaLocalDate())
}
},
position = positionOf(1, 3),
selected = endMode == RecurrenceEndMode.Until,
onClick = {
endMode = RecurrenceEndMode.Until
showUntilPicker = true
},
)
GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_count),
position = positionOf(2, 3),
selected = endMode == RecurrenceEndMode.Count,
onClick = { endMode = RecurrenceEndMode.Count },
)
if (endMode == RecurrenceEndMode.Count) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
) {
DialogAmountField(
value = countText,
onValueChange = { countText = it },
placeholder = "10",
)
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.event_edit_recurrence_times),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
}
if (showUntilPicker) {
CalendarDatePickerDialog(
@@ -1701,24 +1701,14 @@ private fun VisibilityPickerDialog(
onSelect: (AccessLevel) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_visibility)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
AccessLevel.entries.forEach { level ->
OptionCard(
label = stringResource(accessLevelLabel(level)),
onClick = { onSelect(level) },
icon = accessLevelIcon(level),
selected = level == selected,
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
OptionPicker(
title = stringResource(R.string.event_edit_visibility),
options = AccessLevel.entries.toList(),
selected = selected,
label = { stringResource(accessLevelLabel(it)) },
onSelect = onSelect,
onDismiss = onDismiss,
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
)
}
@@ -1741,51 +1731,57 @@ private fun ColorPickerDialog(
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_color)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (palette.isNotEmpty()) {
ColorSwatchRow(
colors = palette.map { it.argb },
selected = selected,
onSelect = { argb ->
palette.firstOrNull { it.argb == argb }
?.let { onPickKey(it.key, it.argb) }
},
dark = dark,
)
} else {
ColorSwatchRow(
colors = CalendarColorPalette.all,
selected = selected,
onSelect = onPickRaw,
dark = dark,
)
if (syncWarning) {
Text(
text = stringResource(R.string.event_edit_color_sync_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
dismissButton = if (hasExplicitColor) {
{
FullScreenPicker(
title = stringResource(R.string.event_edit_color),
onDismiss = onDismiss,
actions = {
if (hasExplicitColor) {
TextButton(onClick = onClear) {
Text(stringResource(R.string.event_edit_color_reset))
}
}
} else {
null
},
)
) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
) {
if (palette.isNotEmpty()) {
// The event's current colour may not be in the curated palette
// (a thinned near-duplicate, or a raw colour set elsewhere) —
// append it so the selection ring has a home.
val swatches = palette.map { it.argb }.let {
if (selected != null && selected !in it) it + selected else it
}
ColorSwatchRow(
colors = swatches,
selected = selected,
onSelect = { argb ->
val option = palette.firstOrNull { it.argb == argb }
// The appended current colour has no provider key to
// write — it is already the event's colour, so just
// close.
if (option != null) onPickKey(option.key, option.argb) else onDismiss()
},
dark = dark,
)
} else {
ColorSwatchRow(
colors = CalendarColorPalette.all,
selected = selected,
onSelect = onPickRaw,
dark = dark,
)
if (syncWarning) {
Text(
text = stringResource(R.string.event_edit_color_sync_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) {

View File

@@ -160,7 +160,7 @@ 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
* [CalendarHost]; Language opens a full-screen picker; About is a card
* at the top. A full-screen destination; [onBack] pops it.
*/
@Composable

View File

@@ -220,7 +220,7 @@
<string name="search_action">Suchen</string>
<string name="search_hint">Termine suchen</string>
<string name="search_back">Zurück</string>
<string name="search_clear">Löschen</string>
<string name="search_clear">Leeren</string>
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
<!-- Startbildschirm-Widgets -->
@@ -310,7 +310,7 @@
<string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Design, Standartansicht, Wochenstart</string>
<string name="settings_appearance_subtitle">Design, Standardansicht, 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>
@@ -330,7 +330,7 @@
<string name="calendars_add">Kalender hinzufügen</string>
<string name="calendars_synced_header">Synchronisierte Kalender</string>
<string name="calendars_synced_hint">Diese stammen von Konten auf deinem Gerät. Erstelle und bearbeite sie in der jeweiligen App.</string>
<string name="calendars_manage_in_app">Verwalten</string>
<string name="calendars_manage_in_app">In der App verwalten</string>
<string name="calendars_add_account">Konto hinzufügen</string>
<string name="calendars_new_title">Neuer Kalender</string>
<string name="calendars_edit_title">Kalender bearbeiten</string>
@@ -394,10 +394,10 @@
<item quantity="other">%d Tage</item>
</plurals>
<plurals name="duration_weeks">
<item quantity="one">Woche</item>
<item quantity="other">Wochen</item>
<item quantity="one">%d Woche</item>
<item quantity="other">%d Wochen</item>
</plurals>
<string name="settings_default_view">Standartansicht</string>
<string name="settings_default_view">Standardansicht</string>
<string name="settings_font_headings">Überschriften Schriftart</string>
<string name="settings_font_system">Systemstandard</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
@@ -411,4 +411,83 @@
<string name="settings_past_events_hide">Ausblenden</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_special_dates_reminders">Erinnerungen</string>
<string name="settings_font_body">Inhaltsschriftart</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="font_lora">Lora</string>
<string name="settings_font_custom_selected">Benutzerdefinierte Schriftart</string>
<string name="settings_section_views">Ansichten</string>
<string name="settings_quick_switch_header">Schnellwechselknopf</string>
<string name="settings_quick_switch_hint">Wähle aus, durch welche Ansichten du mit dem Knopf oben rechts wechseln möchtest, und ziehe, um sie neu anzuordnen. Deaktivierte Ansichten bleiben über das Navigationsmenü erreichbar.</string>
<string name="settings_drawer_order_header">Navigationsmenü</string>
<string name="settings_drawer_order_hint">Ziehen, um die im Navigationsmenü aufgelisteten Ansichten neu anzuordnen.</string>
<string name="reorder_drag_handle">Zum Neuanordnen ziehen</string>
<string name="settings_calendar_reminders_title">Erinnerungen pro Kalender</string>
<string name="settings_translate">Hilf beim Übersetzen</string>
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
<string name="settings_views_subtitle">Schnellwechsellnopf und Menüreihenfolge</string>
<string name="settings_special_dates_subtitle">Kontakte-Geburtstage und Jubiläen</string>
<string name="settings_section_special_dates">Besondere Termine von Kontakten</string>
<string name="settings_special_dates_enable">Kontakttermine anzeigen</string>
<string name="settings_special_dates_enable_hint">Spiegle die Geburtstage und andere Termine deiner Kontakte in lokale Kalender. Liest nur Kontakte auf diesem Gerät es wird nichts hochgeladen und Ihre Kontakte werden nie geändert.</string>
<string name="settings_special_dates_type_birthday">Geburtstage</string>
<string name="settings_special_dates_type_anniversary">Jahrestage</string>
<string name="settings_special_dates_type_custom">Andere Ereignisse</string>
<string name="settings_special_dates_template">Titelformat</string>
<string name="settings_special_dates_template_hint">Verwende {Name} für den Kontakt und {Jahr} für das Jahr (das Geburtsjahr oder das Anfangsjahr eines Jahrestages; versteckt, wenn unbekannt).</string>
<string name="settings_special_dates_show_year">Jahr anzeigen</string>
<string name="settings_special_dates_show_year_hint">{year} in Titel einfügen, wenn bekannt</string>
<string name="settings_special_dates_sync_now">Jetzt synchronisieren</string>
<string name="settings_special_dates_never_synced">Noch nicht synchronisiert</string>
<string name="settings_special_dates_last_synced">Zuletzt synchronisiert %1$s</string>
<string name="settings_special_dates_calendar_hint">Lege die Farbe und Sichtbarkeit jedes Kalenders in den Kalendereinstellungen fest.</string>
<string name="settings_special_dates_paused_title">Pausiert</string>
<string name="settings_calendar_reminders_managed_hint">In den besonderen Terminen der Kontakte setzen</string>
<string name="settings_special_dates_paused_hint">Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert.</string>
<string name="settings_special_dates_disable_title">Kontakttermine deaktivieren?</string>
<string name="settings_special_dates_disable_all_message">Dadurch werden die Kontaktterminkalender und ihre Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string>
<string name="settings_special_dates_grant">Zugriff gewähren</string>
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string>
<string name="settings_special_dates_disable_confirm">Deaktivieren</string>
<string name="dialog_save">Speichern</string>
<string name="calendars_disable_hint">Deaktiviere einen Kalender, um ihn aus der App zu entfernen seine Ereignisse, Filter und Auswahlmöglichkeiten. Es wird nichts gelöscht und du kannst ihn hier jederzeit wieder aktivieren.</string>
<string name="calendars_show_in_app_a11y">„%1$s“ in der App anzeigen</string>
<string name="calendars_account_menu_a11y">Weitere Optionen für %1$s</string>
<string name="calendars_enable_all">Alle aktivieren</string>
<string name="calendars_disable_all">Alle deaktivieren</string>
<string name="calendars_auto_backup">Automatische Sicherung</string>
<string name="calendars_auto_backup_hint">Exportiere deine lokalen Kalender regelmäßig als .ics-Datei in einen Ordner.</string>
<string name="calendars_auto_backup_folder">Sicherungsordner</string>
<string name="calendars_auto_backup_folder_unset">Tippe, um einen Ordner auszuwählen</string>
<string name="calendars_auto_backup_interval">Intervall</string>
<string name="calendars_auto_backup_every">Alle %1$s</string>
<string name="calendars_auto_backup_interval_min">Mindestens 30 Minuten.</string>
<string name="calendars_auto_backup_status_never">Noch keine automatische Sicherung</string>
<string name="calendars_auto_backup_status_ok">Letzte Sicherung: %1$s</string>
<string name="calendars_auto_backup_status_failed">Letzte Sicherung fehlgeschlagen: %1$s</string>
<string name="backup_channel_name">Sicherung</string>
<string name="backup_channel_description">Warnt, wenn automatische Sicherungen wiederholt fehlschlagen.</string>
<string name="backup_failed_title">Automatische Sicherung fehlgeschlagen</string>
<string name="backup_failed_text">Calendula konnte die Sicherungsdatei nicht schreiben. Überprüfe den Sicherungsordner in den Einstellungen.</string>
<string name="special_dates_calendar_birthday">Geburtstage</string>
<string name="special_dates_calendar_anniversary">Jahrestage</string>
<string name="special_dates_calendar_custom">Besondere Termine</string>
<string name="special_dates_default_title_birthday">Geburtstag von {name} ({year})</string>
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
<string name="settings_week_numbers">Kalenderwochen</string>
<string name="settings_week_numbers_summary">Kalenderwochen der Monatsansicht anzeigen</string>
<string name="calendars_export_title">Kalender exportieren</string>
<string name="calendars_export_hint">Wähle aus, welche Kalender in die .ics-Datei aufgenommen werden sollen.</string>
<string name="calendars_export_action">Exportieren</string>
<string name="calendars_restore_header">Wiederherstellen</string>
<string name="calendars_restore_action">Aus .ics-Datei wiederherstellen</string>
<string name="calendars_restore_hint">Importiere Ereignisse aus einem Backup oder einer anderen Kalender-App.</string>
<string name="import_done_dedup_note">Bereits im Kalender vorhandene Ereignisse wurden übersprungen.</string>
<string name="import_done_added_label">Hinzugefügt</string>
<string name="import_done_skipped_label">Duplikate</string>
<string name="import_button">Importieren</string>
<plurals name="import_title_count">
<item quantity="one">%d Ereignis wird importiert</item>
<item quantity="other">%d Ereignisse werden importiert</item>
</plurals>
</resources>

View File

@@ -16,17 +16,17 @@
<string name="state_failure_no_calendars">Nessun calendario configurato.</string>
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
<string name="permission_rationale_title">Guarda tutti i tuoi eventi, magnificamente</string>
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa che ti chiederà.</string>
<string name="permission_rationale_title">Gestisci magnificamente tutti i tuoi eventi</string>
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno e nessuna informazione lascerà mai il tuo dispositivo.</string>
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
<string name="permission_denied_title">Accesso al calendario negato</string>
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
<string name="permission_retry_button">Riprova</string>
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo telefono.</string>
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo dispositivo.</string>
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo semplicemente compare.</string>
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo, semplicemente, compare.</string>
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
<string name="permission_benefit_privacy_body">Zero telemetria, zero analytics, zero pubblicità.</string>
<string name="permission_privacy_footnote">Rimane sul tuo dispositivo - nessun accesso a internet</string>
@@ -38,7 +38,7 @@
<string name="month_action_settings">Impostazioni</string>
<string name="month_a11y_today_prefix">Oggi</string>
<string name="week_today_action">Questa settimana</string>
<string name="week_number_label">Wk</string>
<string name="week_number_label">Sett.</string>
<string name="day_today_action">Oggi</string>
<string name="event_detail_back">Indietro</string>
<string name="event_detail_edit">Modifica</string>
@@ -73,14 +73,14 @@
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
<string name="event_edit_attendees">Partecipanti</string>
<string name="event_edit_add_guest">Aggiungi partecipante</string>
<string name="event_edit_add_guest_hint">Aggiungi un partecipante via email…</string>
<string name="event_edit_add_guest_hint">Aggiungi un partecipante tramite indirizzo email…</string>
<string name="event_edit_add_guest_from_contacts">Aggiungi dai contatti</string>
<string name="event_edit_location_from_contacts">Scegli un indirizzo dai contatti</string>
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
<string name="event_edit_attendee_required">Obbligatorio</string>
<string name="event_edit_attendee_optional">Facoltativo</string>
<string name="event_edit_attendees_note_synced">Calendula non manda gli inviti agli eventi. Il tuo account calendario potrà inviare le email ai partecipanti al momento della sincronizzazione.</string>
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno ha ricevuto notifiche.</string>
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno dei partecipanti ha ricevuto notifiche.</string>
<string name="event_edit_reminder_custom">Personalizza</string>
<string name="reminder_unit_minutes">minuti</string>
<string name="reminder_unit_hours">ore</string>
@@ -89,7 +89,7 @@
<string name="event_edit_availability">Disponibilità</string>
<string name="event_edit_visibility">Visibilità</string>
<string name="event_edit_color">Colore</string>
<string name="event_edit_color_default">Colore calendario</string>
<string name="event_edit_color_default">Colore del calendario</string>
<string name="event_edit_color_custom">Colore personalizzato</string>
<string name="event_edit_color_reset">Reset</string>
<string name="event_edit_color_unsupported">Non disponibile per questo calendario</string>
@@ -99,13 +99,13 @@
<string name="event_edit_conflict_body">Durante la modifica l\'evento è stato cambiato, tramite sincronizzazione o tramite altra app. Cosa fare con le modifiche in corso?</string>
<string name="event_edit_conflict_overwrite">Salva le modifiche</string>
<string name="event_edit_conflict_overwrite_hint">Sovrascrivi solo i campi modificati</string>
<string name="event_edit_conflict_discard">Annulla le mie modifiche</string>
<string name="event_edit_conflict_discard_hint">Mantieni l\'evento com\'è ora</string>
<string name="event_edit_conflict_discard">Annulla le modifiche</string>
<string name="event_edit_conflict_discard_hint">Lascia l\'evento invariato</string>
<string name="event_edit_gone_title">Evento eliminato</string>
<string name="event_edit_gone_body">Nel frattempo, questo evento è stato eliminato, ad esempio su un altro dispositivo. Le modifiche non possono più essere salvate.</string>
<string name="event_edit_recurrence_none">Non si ripete</string>
<string name="event_edit_recurrence_custom">Personalizza</string>
<string name="event_edit_recurrence_every">La serie</string>
<string name="event_edit_recurrence_every">Ogni</string>
<string name="recurrence_unit_days">giorni</string>
<string name="recurrence_unit_weeks">settimane</string>
<string name="recurrence_unit_months">mesi</string>
@@ -113,7 +113,7 @@
<string name="event_edit_recurrence_ends">Fine</string>
<string name="event_edit_recurrence_end_never">Mai</string>
<string name="event_edit_recurrence_end_until">Fino alla data</string>
<string name="event_edit_recurrence_end_count">Dopo un certo numero di occorrenze</string>
<string name="event_edit_recurrence_end_count">Numero di occorrenze</string>
<string name="event_edit_recurrence_times">occorrenze</string>
<string name="event_edit_error_recurrence_ends_before_start">Fine antecedente l\'inizio</string>
<string name="event_availability_busy">Occupato</string>
@@ -125,7 +125,7 @@
<string name="event_detail_location">Luogo</string>
<string name="event_detail_description">Descrizione</string>
<string name="event_detail_attendees">Partecipanti</string>
<string name="recurrence_daily">Ogni giorni</string>
<string name="recurrence_daily">Ogni giorno</string>
<string name="recurrence_weekly">Ogni settimana</string>
<string name="recurrence_monthly">Ogni mese</string>
<string name="recurrence_yearly">Ogni anno</string>
@@ -204,7 +204,7 @@
<string name="reminder_benefit_reversible_body">Lo switch è nelle Impostazioni, sezione Notifiche.</string>
<string name="reminder_onboarding_enable_button">Attiva i promemoria</string>
<string name="reminder_onboarding_skip_button">Non ora</string>
<string name="reminder_action_snooze">Posponi</string>
<string name="reminder_action_snooze">Posticipa</string>
<string name="reminder_action_dismiss">Ignora</string>
<string name="view_month">Mese</string>
<string name="view_week">Settimana</string>
@@ -418,16 +418,16 @@
<string name="settings_default_reminder">Promemoria standard</string>
<string name="settings_notifications_subtitle">Promemoria evento</string>
<string name="shortcut_new_event_short">Nuovo evento</string>
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente</string>
<string name="settings_font_headings">Carattere dell\'intestazione</string>
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
<string name="settings_font_headings">Carattere del titolo</string>
<string name="settings_font_body">Carattere del corpo</string>
<string name="settings_font_system">Predefinito di sistema</string>
<string name="settings_font_choose_file">Scegli file…</string>
<string name="settings_font_custom_selected">Carattere personalizzato</string>
<string name="settings_font_custom_selected">Font personalizzato</string>
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
<string name="settings_section_views">Viste</string>
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere mediante il pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
<string name="settings_drawer_order_header">Menù di navigazione</string>
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
<string name="reorder_drag_handle">Trascina per riordinare</string>
@@ -456,4 +456,14 @@
<string name="settings_special_dates_disable_type_message">Questo cancella il calendario “%1$s” ed i suoi eventi. Ogni promemoria o note che erano stati aggiunti saranno persi.</string>
<string name="settings_special_dates_disable_confirm">Spegni</string>
<string name="dialog_save">Salva</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="settings_calendar_reminders_managed_hint">Impostato nelle date speciali dei contatti</string>
<string name="special_dates_calendar_birthday">Compleanni</string>
<string name="special_dates_calendar_anniversary">Anniversari</string>
<string name="special_dates_calendar_custom">Date speciali</string>
<string name="special_dates_default_title_birthday">Compleanno di {name} ({year})</string>
<string name="special_dates_default_title_anniversary">Anniversario di {name} ({year})</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="special_dates_default_title_custom">{name}</string>
</resources>

View File

@@ -0,0 +1,214 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class EventColorPaletteTest {
@Test
fun `empty palette stays empty`() {
assertThat(emptyList<EventColorOption>().curatedForPicker()).isEmpty()
}
@Test
fun `exact duplicate values collapse to the alphabetically first key`() {
val curated = listOf(
EventColorOption("cyan", 0xFF00FFFF.toInt()),
EventColorOption("aqua", 0xFF00FFFF.toInt()),
EventColorOption("red", 0xFFFF0000.toInt()),
).curatedForPicker()
assertThat(curated.map { it.key }).containsExactly("aqua", "red")
}
@Test
fun `small palettes pass through whole, so Google's curated set is untouched`() {
// A Google-like palette: two dozen distinct hand-picked colours.
val palette = (0 until 24).map {
val hue = it * 15
EventColorOption("$it", hsvArgb(hue.toFloat()))
}
val curated = palette.curatedForPicker()
assertThat(curated).containsExactlyElementsIn(palette)
}
@Test
fun `oversized CSS3 palette thins to a pickable number of distinct swatches`() {
val curated = css3Palette().curatedForPicker()
// The whole point of #22: ~147 published colours become a single
// manageable grid instead of a full screen.
assertThat(curated.size).isAtLeast(30)
assertThat(curated.size).isAtMost(60)
}
@Test
fun `curation never invents colours or drops keys`() {
val source = css3Palette()
val curated = source.curatedForPicker()
assertThat(source).containsAtLeastElementsIn(curated)
assertThat(curated.map { it.argb }).containsNoDuplicates()
}
@Test
fun `spelling-alias pairs never both survive`() {
val keys = css3Palette().curatedForPicker().map { it.key }.toSet()
val aliasPairs = listOf(
"aqua" to "cyan",
"fuchsia" to "magenta",
"gray" to "grey",
"darkgray" to "darkgrey",
"dimgray" to "dimgrey",
"lightgray" to "lightgrey",
"slategray" to "slategrey",
"lightslategray" to "lightslategrey",
"darkslategray" to "darkslategrey",
)
aliasPairs.forEach { (a, b) ->
assertThat(keys.contains(a) && keys.contains(b)).isFalse()
}
}
@Test
fun `neutrals collapse to one painted tint instead of a run of look-alikes`() {
// Black and every gray paint as the same pale swatch (the picker pins
// lightness and floors saturation), so only one survives — no stranded
// run of look-alike "pinks" at the end of the grid (#22).
val curated = listOf(
EventColorOption("black", 0xFF000000.toInt()),
EventColorOption("gray", 0xFF808080.toInt()),
EventColorOption("darkgray", 0xFFA9A9A9.toInt()),
EventColorOption("blue", 0xFF0000FF.toInt()),
EventColorOption("red", 0xFFFF0000.toInt()),
).curatedForPicker().map { it.key }
assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black
assertThat(curated).containsAtLeast("black", "red", "blue")
}
@Test
fun `a dark and a light shade of one hue collapse to a single swatch`() {
// The picker paints every swatch at one fixed lightness, so navy and a
// mid blue are indistinguishable once painted — keep just one.
val curated = listOf(
EventColorOption("navy", 0xFF000080.toInt()),
EventColorOption("blue", 0xFF0000FF.toInt()),
).curatedForPicker()
assertThat(curated).hasSize(1)
}
@Test
fun `the wheel is cut once, keeping each hue family contiguous`() {
// Twelve pure hues, deliberately shuffled; a small palette passes the
// thinning stage untouched so only the ordering is under test.
val shuffledHues = listOf(0, 300, 60, 180, 120, 240, 30, 330, 90, 210, 150, 270)
val curated = shuffledHues
.map { EventColorOption("$it", hsvArgb(it.toFloat())) }
.curatedForPicker()
.map { it.key.toInt() }
// A proper single-seam sweep around the wheel descends exactly once
// (at the seam). The old bucketed sort could scatter a family across
// both ends, producing extra descents.
val descents = curated.indices.count { i ->
curated[(i + 1) % curated.size] < curated[i]
}
assertThat(descents).isEqualTo(1)
}
@Test
fun `CSS3 survivors span the whole rainbow`() {
val keys = css3Palette().curatedForPicker().map { it.key }
fun has(vararg families: String) = keys.any { k -> families.any { k.contains(it) } }
// Which exact name represents a hue family depends on the vivid-first
// thinning, so assert each family survives, not a specific key.
assertThat(has("red", "crimson", "firebrick", "tomato", "maroon", "brown")).isTrue()
assertThat(has("orange", "gold", "goldenrod", "peru", "sienna", "salmon")).isTrue()
assertThat(has("green", "olive", "lime", "chartreuse", "forest", "sea")).isTrue()
assertThat(has("blue", "navy", "dodger", "steel", "royal", "sky", "aqua")).isTrue()
assertThat(has("violet", "purple", "magenta", "orchid", "fuchsia", "indigo", "plum")).isTrue()
}
private fun hsvArgb(hue: Float): Int {
val h = hue / 60f
val sector = h.toInt() % 6
val f = h - h.toInt()
val q = ((1 - f) * 255).toInt()
val t = (f * 255).toInt()
return when (sector) {
0 -> argb(255, t, 0)
1 -> argb(q, 255, 0)
2 -> argb(0, 255, t)
3 -> argb(0, q, 255)
4 -> argb(t, 0, 255)
else -> argb(255, 0, q)
}
}
private fun argb(r: Int, g: Int, b: Int): Int =
(0xFF shl 24) or (r shl 16) or (g shl 8) or b
/** The exact set ical4android/DAVx5 publishes: CSS3's 147 named colours. */
private fun css3Palette(): List<EventColorOption> = CSS3.map { (name, rgb) ->
EventColorOption(name, 0xFF000000.toInt() or rgb)
}
private val CSS3 = mapOf(
"aliceblue" to 0xF0F8FF, "antiquewhite" to 0xFAEBD7, "aqua" to 0x00FFFF,
"aquamarine" to 0x7FFFD4, "azure" to 0xF0FFFF, "beige" to 0xF5F5DC,
"bisque" to 0xFFE4C4, "black" to 0x000000, "blanchedalmond" to 0xFFEBCD,
"blue" to 0x0000FF, "blueviolet" to 0x8A2BE2, "brown" to 0xA52A2A,
"burlywood" to 0xDEB887, "cadetblue" to 0x5F9EA0, "chartreuse" to 0x7FFF00,
"chocolate" to 0xD2691E, "coral" to 0xFF7F50, "cornflowerblue" to 0x6495ED,
"cornsilk" to 0xFFF8DC, "crimson" to 0xDC143C, "cyan" to 0x00FFFF,
"darkblue" to 0x00008B, "darkcyan" to 0x008B8B, "darkgoldenrod" to 0xB8860B,
"darkgray" to 0xA9A9A9, "darkgreen" to 0x006400, "darkgrey" to 0xA9A9A9,
"darkkhaki" to 0xBDB76B, "darkmagenta" to 0x8B008B, "darkolivegreen" to 0x556B2F,
"darkorange" to 0xFF8C00, "darkorchid" to 0x9932CC, "darkred" to 0x8B0000,
"darksalmon" to 0xE9967A, "darkseagreen" to 0x8FBC8F, "darkslateblue" to 0x483D8B,
"darkslategray" to 0x2F4F4F, "darkslategrey" to 0x2F4F4F, "darkturquoise" to 0x00CED1,
"darkviolet" to 0x9400D3, "deeppink" to 0xFF1493, "deepskyblue" to 0x00BFFF,
"dimgray" to 0x696969, "dimgrey" to 0x696969, "dodgerblue" to 0x1E90FF,
"firebrick" to 0xB22222, "floralwhite" to 0xFFFAF0, "forestgreen" to 0x228B22,
"fuchsia" to 0xFF00FF, "gainsboro" to 0xDCDCDC, "ghostwhite" to 0xF8F8FF,
"gold" to 0xFFD700, "goldenrod" to 0xDAA520, "gray" to 0x808080,
"green" to 0x008000, "greenyellow" to 0xADFF2F, "grey" to 0x808080,
"honeydew" to 0xF0FFF0, "hotpink" to 0xFF69B4, "indianred" to 0xCD5C5C,
"indigo" to 0x4B0082, "ivory" to 0xFFFFF0, "khaki" to 0xF0E68C,
"lavender" to 0xE6E6FA, "lavenderblush" to 0xFFF0F5, "lawngreen" to 0x7CFC00,
"lemonchiffon" to 0xFFFACD, "lightblue" to 0xADD8E6, "lightcoral" to 0xF08080,
"lightcyan" to 0xE0FFFF, "lightgoldenrodyellow" to 0xFAFAD2, "lightgray" to 0xD3D3D3,
"lightgreen" to 0x90EE90, "lightgrey" to 0xD3D3D3, "lightpink" to 0xFFB6C1,
"lightsalmon" to 0xFFA07A, "lightseagreen" to 0x20B2AA, "lightskyblue" to 0x87CEFA,
"lightslategray" to 0x778899, "lightslategrey" to 0x778899, "lightsteelblue" to 0xB0C4DE,
"lightyellow" to 0xFFFFE0, "lime" to 0x00FF00, "limegreen" to 0x32CD32,
"linen" to 0xFAF0E6, "magenta" to 0xFF00FF, "maroon" to 0x800000,
"mediumaquamarine" to 0x66CDAA, "mediumblue" to 0x0000CD, "mediumorchid" to 0xBA55D3,
"mediumpurple" to 0x9370DB, "mediumseagreen" to 0x3CB371, "mediumslateblue" to 0x7B68EE,
"mediumspringgreen" to 0x00FA9A, "mediumturquoise" to 0x48D1CC,
"mediumvioletred" to 0xC71585, "midnightblue" to 0x191970, "mintcream" to 0xF5FFFA,
"mistyrose" to 0xFFE4E1, "moccasin" to 0xFFE4B5, "navajowhite" to 0xFFDEAD,
"navy" to 0x000080, "oldlace" to 0xFDF5E6, "olive" to 0x808000,
"olivedrab" to 0x6B8E23, "orange" to 0xFFA500, "orangered" to 0xFF4500,
"orchid" to 0xDA70D6, "palegoldenrod" to 0xEEE8AA, "palegreen" to 0x98FB98,
"paleturquoise" to 0xAFEEEE, "palevioletred" to 0xDB7093, "papayawhip" to 0xFFEFD5,
"peachpuff" to 0xFFDAB9, "peru" to 0xCD853F, "pink" to 0xFFC0CB,
"plum" to 0xDDA0DD, "powderblue" to 0xB0E0E6, "purple" to 0x800080,
"red" to 0xFF0000, "rosybrown" to 0xBC8F8F, "royalblue" to 0x4169E1,
"saddlebrown" to 0x8B4513, "salmon" to 0xFA8072, "sandybrown" to 0xF4A460,
"seagreen" to 0x2E8B57, "seashell" to 0xFFF5EE, "sienna" to 0xA0522D,
"silver" to 0xC0C0C0, "skyblue" to 0x87CEEB, "slateblue" to 0x6A5ACD,
"slategray" to 0x708090, "slategrey" to 0x708090, "snow" to 0xFFFAFA,
"springgreen" to 0x00FF7F, "steelblue" to 0x4682B4, "tan" to 0xD2B48C,
"teal" to 0x008080, "thistle" to 0xD8BFD8, "tomato" to 0xFF6347,
"turquoise" to 0x40E0D0, "violet" to 0xEE82EE, "wheat" to 0xF5DEB3,
"white" to 0xFFFFFF, "whitesmoke" to 0xF5F5F5, "yellow" to 0xFFFF00,
"yellowgreen" to 0x9ACD32,
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View File

@@ -0,0 +1,25 @@
Calendula es una aplicación de calendario moderna y de código abierto
para Android. Funciona directamente sobre el proveedor de calendario del
sistema: cualquier origen sincronizado en tu dispositivo — Nextcloud
mediante DAVx5, Google, calendarios locales, suscripciones WebCal —
aparece automáticamente, y los cambios que hagas se sincronizan de vuelta
del mismo modo.
Crea, edita y elimina eventos, incluidos los recurrentes con cambios
acotados (solo este evento / este y todos los siguientes / toda la serie)
y un sencillo selector de repetición. Calendula también entrega los
recordatorios de tus eventos como notificaciones: toca una y estarás en
el evento.
Si quieres, muestra en tu calendario los cumpleaños, aniversarios y otras
fechas especiales de tus contactos. Tú mismo lo activas; los contactos se
leen solo en tu dispositivo, nunca se suben y nunca se modifican —
Calendula solo refleja las fechas en calendarios locales que controlas
por completo.
El factor diferenciador es el diseño: auténtico Material 3 Expressive en
toda la aplicación, con Dynamic Color, movimiento expresivo y formas
expresivas.
Privacidad: cero telemetría, sin analíticas, sin acceso a la red — tus
datos nunca salen del dispositivo.

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -0,0 +1 @@
Un calendario moderno con Material 3 Expressive para Android.

View File

@@ -0,0 +1 @@
Calendula

View File

@@ -0,0 +1,24 @@
Calendula è un'app di calendario moderna e open source per Android.
Funziona direttamente sul provider di calendario di sistema: qualsiasi
origine sincronizzata sul tuo dispositivo — Nextcloud tramite DAVx5,
Google, calendari locali, iscrizioni WebCal — compare automaticamente, e
le modifiche che apporti vengono sincronizzate a ritroso allo stesso
modo.
Crea, modifica ed elimina eventi, compresi quelli ricorrenti con
modifiche mirate (solo questo evento / questo e tutti i successivi /
l'intera serie) e un semplice selettore di ripetizione. Calendula
recapita anche i promemoria dei tuoi eventi come notifiche: ne tocchi una
e sei subito sull'evento.
Se vuoi, mostra nel calendario i compleanni, gli anniversari e le altre
date speciali dei tuoi contatti. Sei tu ad attivarlo; i contatti vengono
letti solo sul tuo dispositivo, non vengono mai caricati e non vengono
mai modificati — Calendula si limita a rispecchiare le date in calendari
locali che controlli completamente.
La differenza sta nel design: vero Material 3 Expressive ovunque, con
Dynamic Color, animazioni espressive e forme espressive.
Privacy: zero telemetria, nessuna analisi, nessun accesso alla rete — i
tuoi dati non lasciano mai il dispositivo.

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -0,0 +1 @@
Un moderno calendario Material 3 Expressive per Android.

View File

@@ -0,0 +1 @@
Calendula