Compare commits

...

6 Commits

Author SHA1 Message Date
b3617d60e5 fix(deps): update lifecyclecompose to v2.11.0 2026-08-06 18:27:58 +00:00
Jean-Luc Makiola
66dbdc8913 2.18.1 — create at the tapped hour after a zoom (#155)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 9s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 14m28s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m4s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/155
2026-08-06 20:09:50 +02:00
Jean-Luc Makiolas Weblate Bot
6a125b7c73 Translations update from Weblate (#136)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 32s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/136
2026-08-06 19:40:09 +02:00
Jean-Luc Makiolas Weblate Bot
44233cff35 Translations update from Weblate (#130)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 30s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Renovate / renovate (push) Successful in 1m37s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/130
2026-08-02 17:45:31 +02:00
Jean-Luc Makiolas Weblate Bot
5764bd889b Translations update from Weblate (#129)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 30s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Translations update from [Weblate](https://weblate.dev.jeanlucmakiola.de) for [Calendula/Strings](https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/).

Current translation status:

![Weblate translation status](https://weblate.dev.jeanlucmakiola.de/widget/calendula/strings/horizontal-auto.svg)

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Admin <business@jeanlucmakiola.de>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: augustin64 <me.git+calendula@augustin64.fr>
Co-authored-by: Thomas Tref <Trefthomas33@gmail.com>
Co-authored-by: Xavier <bxavier52@laposte.net>
Co-authored-by: Weblate <noreply@weblate.org>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/129
2026-08-01 15:42:13 +02:00
Jean-Luc Makiola
a8a83a39d0 2.18.0 (#126)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 13m15s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m34s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/126
2026-07-31 22:22:10 +02:00
44 changed files with 1229 additions and 147 deletions

View File

@@ -264,9 +264,10 @@ jobs:
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
# Per-version "What's New": ensure this version's changelog exists in the
# fastlane tree (committed at release-cut time for the official repo; this
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
# the commit having happened). The transform below then carries it across.
# fastlane tree. The committed hand-written summary (kept under Play's
# 500-char cap) is used as-is; only if it is missing does the script fall
# back to CHANGELOG.md, so the self-hosted repo never depends on the
# commit having happened. The transform below then carries it across.
- name: Ensure this version's changelog is in the fastlane tree
if: env.IS_RELEASE == 'true'
run: bash scripts/sync_changelog_to_fastlane.sh

View File

@@ -7,7 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.18.1] — 2026-08-06
### Fixed
- Tapping an empty slot in week or day view creates the event at the hour you
tapped, after the timeline has been zoomed. Pinching the day taller or shorter
— or changing **Hour height** in Settings — left the tap still being measured
against the old spacing, so the new event landed at some other hour entirely
([#148]).
## [2.18.0] — 2026-07-31
### Added
- A new event no longer always lasts an hour. **Settings → New event form →
Default duration** sets how long one opens, and each calendar may keep its own
length underneath — 8 hours for the calendar you keep work shifts in, 30
minutes for the one you book calls in. A calendar without its own length
follows the default, switching calendars mid-form re-stretches the event, and
setting an end time by hand keeps it. An event another app hands over with only
a start gets the default too; one that names its own end — an `.ics` file, a
duplicate — keeps that length. All-day events are unaffected ([#54]).
- Week and day view can be set to show more or less of the day at once, under
Settings → Views → Week & day → **Hour height**. **Fit whole day** sizes an
hour to your screen so all 24 hours are visible without scrolling — on a tall
@@ -22,6 +41,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
tapping a named step there takes you back to it ([#56]).
### Changed
- The date in the top bar is now the way to jump: tapping the month, week or day
title opens the same date picker the sidebar's **Jump to date** offers, seeded
on whatever the bar is naming. A drop-down caret marks it as tappable. The
sidebar entry stays where it is ([#57]).
- A reminder in the status bar is now Calendula's own mark — the calendar with
the bloom — instead of the generic calendar glyph it shared with the system's
date surfaces and every other calendar app. The status bar draws that icon as
a plain silhouette, so its shape is the only thing that could tell them apart
([#83]).
- Event blocks now only draw text they can draw whole. One too short for a full
line shows no title rather than a sliced one, a block that cannot fit both its
title and its time keeps the title, and a block too narrow to hold more than a
@@ -40,6 +68,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
is now called **Harmonise calendar colours**; turning it off still shows the
raw colours from your calendar source ([#21], [#36]).
### Fixed
- The back gesture closes the sidebar instead of the app. With the drawer open,
swiping back left Calendula altogether rather than putting the sidebar away
([#114]).
- The sidebar lines up. "Calendula", "View" and "Calendars" now share the left
edge of the rows under them, and the view, jump-to-date, Settings and calendar
rows sit on one vertical axis instead of each icon finding its own — the same
alignment the Settings screens use ([#114]).
- The status- and navigation-bar icons follow Calendula's own light/dark choice.
Setting the app dark while the system stayed light — or the other way round —
left the clock and battery drawn for the system's theme, so they could sit
near-invisible against the app's own bar ([#70]).
## [2.17.1] — 2026-07-30
### Added
@@ -1282,8 +1323,13 @@ automatically, with zero telemetry and no internet permission.
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
[#83]: https://codeberg.org/jlmakiola/calendula/issues/83
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
[#54]: https://codeberg.org/jlmakiola/calendula/issues/54
[#57]: https://codeberg.org/jlmakiola/calendula/issues/57
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56
[#114]: https://codeberg.org/jlmakiola/calendula/issues/114
[#148]: https://codeberg.org/jlmakiola/calendula/issues/148

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21701
versionName = "2.17.1"
versionCode = 21801
versionName = "2.18.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -39,6 +39,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
import de.jeanlucmakiola.floret.components.DebugRibbon
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.domain.FontRole
@@ -55,6 +56,9 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
/** A prefilled create form from an external launch, with the source it came from. */
private data class InsertRequest(val form: EventForm, val source: ImportSource)
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@@ -79,7 +83,7 @@ class MainActivity : AppCompatActivity() {
// A prefilled new-event form from an external ACTION_INSERT launch (another
// app/widget asking us to create an event, issue #30). Consumed once by
// CalendarHost, which opens it in the create form for review.
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
// opens the occurrence in the edit form. Same occurrence-key shape as the
@@ -112,7 +116,7 @@ class MainActivity : AppCompatActivity() {
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull()
requestedInsertForm = intent.insertFormOrNull()
requestedInsert = intent.insertRequestOrNull()
requestedEditKey = intent.editEventKeyOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent {
@@ -179,8 +183,9 @@ class MainActivity : AppCompatActivity() {
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsertForm,
onInsertConsumed = { requestedInsertForm = null },
requestedInsertForm = requestedInsert?.form,
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
onInsertConsumed = { requestedInsert = null },
requestedEditKey = requestedEditKey,
onEditKeyConsumed = { requestedEditKey = null },
)
@@ -233,7 +238,7 @@ class MainActivity : AppCompatActivity() {
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it }
intent.insertFormOrNull()?.let { requestedInsertForm = it }
intent.insertRequestOrNull()?.let { requestedInsert = it }
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
}
@@ -260,9 +265,10 @@ class MainActivity : AppCompatActivity() {
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
* event", i.e. create). The new event's fields ride as CalendarContract
* extras; anything omitted falls back to the in-app "new event" defaults in
* [buildInsertEventForm].
* [buildInsertEventForm]. An intent that names no end time leaves the length
* to the default-duration setting ([ImportSource.InsertOpenEnded], #54).
*/
private fun Intent.insertFormOrNull(): EventForm? {
private fun Intent.insertRequestOrNull(): InsertRequest? {
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
// as is any plain ACTION_INSERT.
@@ -270,9 +276,11 @@ class MainActivity : AppCompatActivity() {
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
editEventKeyOrNull() == null)
if (!isCreate) return null
return buildInsertEventForm(
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
val beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME)
val endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME)
val form = buildInsertEventForm(
beginMillis = beginMillis,
endMillis = endMillis,
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
title = getStringExtra(CalendarContract.Events.TITLE),
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
@@ -281,6 +289,10 @@ class MainActivity : AppCompatActivity() {
zone = TimeZone.currentSystemDefault(),
now = Clock.System.now(),
)
// An end the intent didn't name — or one [buildInsertEventForm] drops for
// landing before the start — leaves the length to the setting.
val namesEnd = beginMillis != null && endMillis != null && endMillis >= beginMillis
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
}
/** A Long extra's value, or null when the extra is absent. */

View File

@@ -473,6 +473,46 @@ class SettingsPrefs @Inject constructor(
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
}
/**
* How long a new **timed** event lasts, in minutes (#54). Defaults to
* [DEFAULT_EVENT_DURATION] — the historical fixed hour. Per-calendar
* overrides in [perCalendarEventDuration] take precedence; all-day events are
* date-anchored and ignore it. Resolve with [resolveDefaultEventDuration].
*/
val defaultEventDurationMinutes: Flow<Int> = store.data.map { prefs ->
(prefs[DEFAULT_EVENT_DURATION_KEY] ?: DEFAULT_EVENT_DURATION)
.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
}
suspend fun setDefaultEventDurationMinutes(minutes: Int) {
store.edit {
it[DEFAULT_EVENT_DURATION_KEY] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
}
}
/**
* Per-calendar overrides of [defaultEventDurationMinutes], keyed by calendar
* id: a calendar **present** in the map gives its new events that length, one
* **absent** inherits the global default (there is no "no duration", so this
* needs no `none` sentinel). Serialised as `id=minutes;id=minutes`.
*/
val perCalendarEventDuration: Flow<Map<Long, Int>> = store.data.map { prefs ->
parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY])
}
/** [minutes] null drops the override, so the calendar inherits the global default. */
suspend fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
store.edit { prefs ->
val current = parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY]).toMutableMap()
if (minutes == null) {
current.remove(calendarId)
} else {
current[calendarId] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
}
prefs[CALENDAR_EVENT_DURATION_KEY] = current.toStoredDurations()
}
}
/**
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
* for users whose only calendar app this is, reminders are essential; the
@@ -831,6 +871,15 @@ class SettingsPrefs @Inject constructor(
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
internal val DEFAULT_EVENT_DURATION_KEY = intPreferencesKey("default_event_duration_minutes")
internal val CALENDAR_EVENT_DURATION_KEY =
stringPreferencesKey("per_calendar_event_duration")
/** A new timed event's length until the user changes it: one hour. */
const val DEFAULT_EVENT_DURATION = 60
internal const val MIN_EVENT_DURATION = 1
/** A day — past that the form is describing a multi-day event, not a default. */
internal const val MAX_EVENT_DURATION = 1_440
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
@@ -922,6 +971,17 @@ fun resolveDefaultReminder(
}
}
/**
* The length a new timed event on [calendarId] opens with: that calendar's
* override if it has one, otherwise the [global] default. Pure so it can be
* unit-tested.
*/
fun resolveDefaultEventDuration(
global: Int,
overrides: Map<Long, Int>,
calendarId: Long?,
): Int = calendarId?.let { overrides[it] } ?: global
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
private const val WEEK_START_AUTO = "AUTO"
@@ -969,6 +1029,27 @@ private fun String?.toReminderList(): List<Int> = when {
private fun List<Int>.toStoredReminders(): String =
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
/**
* Parse the per-calendar duration map (`id=minutes` entries joined by `;`).
* Malformed entries and out-of-range lengths are dropped, so a garbled value
* degrades to "inherits the global default" instead of throwing.
*/
private fun parseDurationOverrides(stored: String?): Map<Long, Int> =
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP)
if (parts.size != 2) return@mapNotNull null
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
val minutes = parts[1].trim().toIntOrNull()
?.takeIf { it in SettingsPrefs.MIN_EVENT_DURATION..SettingsPrefs.MAX_EVENT_DURATION }
?: return@mapNotNull null
calendarId to minutes
}.toMap()
private fun Map<Long, Int>.toStoredDurations(): String =
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) ->
"$id$KEY_VALUE_SEP$minutes"
}
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default

View File

@@ -15,7 +15,9 @@ import kotlin.time.Instant
* calendar this way to create a new event, passing the fields as
* [android.provider.CalendarContract] extras. Any field the intent omits falls
* back to the same defaults the in-app "new event" uses — a timed start at the
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so
* next full hour, and a placeholder hour for a missing end that the form then
* stretches to the default-duration setting (the intent is opened as
* `ImportSource.InsertOpenEnded`, #54). [EventForm.calendarId] is left null so
* it resolves to the last-used / first-writable calendar, exactly like the
* `.ics` single-event and plain new-event paths.
*

View File

@@ -75,6 +75,7 @@ fun CalendarHost(
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: EventForm? = null,
requestedInsertSource: ImportSource = ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
@@ -203,7 +204,7 @@ fun CalendarHost(
// reveals on top of whatever was open without extra dismissal.
LaunchedEffect(requestedInsertForm) {
if (requestedInsertForm != null) {
importFormSource = ImportSource.Insert
importFormSource = requestedInsertSource
importForm = requestedInsertForm
onInsertConsumed()
}

View File

@@ -39,6 +39,8 @@ fun RootScreen(
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
@@ -109,6 +111,7 @@ fun RootScreen(
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
requestedInsertSource = requestedInsertSource,
onInsertConsumed = onInsertConsumed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,

View File

@@ -0,0 +1,81 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate
/**
* Top-bar title that doubles as the jump-to-date entry point: the formatted
* [title] with a drop-down caret, opening the same [CalendarDatePickerDialog] the
* drawer uses and reporting the chosen day through [onJumpToDate].
*
* [currentDate] seeds the picker with whatever the bar is currently naming (the
* visible day, week start or month anchor).
*
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
* start alignment: M3 places the title flush against the navigation icon's
* trailing edge, and the title is hit-tested on top of it, so a negative offset
* would swallow taps meant for the menu button.
*/
@Composable
fun CalendarTitleButton(
title: String,
currentDate: LocalDate,
onJumpToDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
var showDatePicker by rememberSaveable { mutableStateOf(false) }
Row(
modifier = modifier
.heightIn(min = 48.dp)
.clip(MaterialTheme.shapes.large)
.clickable(
onClickLabel = stringResource(R.string.drawer_jump_to_date),
role = Role.Button,
) { showDatePicker = true }
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f, fill = false),
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (showDatePicker) {
CalendarDatePickerDialog(
initial = currentDate,
onConfirm = {
showDatePicker = false
onJumpToDate(it)
},
onDismiss = { showDatePicker = false },
)
}
}

View File

@@ -354,35 +354,66 @@ private fun CustomDaysEditor(
}
/**
* Snooze-duration picker, full-screen and **single-select**: the [presets]
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
* a preset or confirming a custom value applies via [onSelect] and closes.
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
* is reused for both the rows and the custom preview.
* Duration picker, full-screen and **single-select**: the [presets] (whole-minute
* lengths) each sit as a checkmark row, with a "Custom" row that expands an
* inline amount field plus a Minutes/Hours unit toggle to enter an arbitrary
* one. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking a preset or
* confirming a custom value applies via [onSelect] and closes. [label] renders a
* length in minutes ("10 minutes", "1 hour") and is reused for both the rows and
* the custom preview.
*
* [inheritLabel] adds an exclusive "use the default" row on top, for the
* per-calendar pickers; picking it reports null. [selected] is null exactly when
* that row is the current choice. [description] explains the setting above the
* rows when it needs it. [maxMinutes] caps what the custom editor will confirm,
* for settings that clamp their stored value — offering a length the store would
* silently shorten is worse than refusing it.
*/
@Composable
fun SnoozeDurationPicker(
fun DurationPicker(
title: String,
presets: List<Int>,
selected: Int,
selected: Int?,
label: @Composable (Int) -> String,
onSelect: (Int) -> Unit,
onSelect: (Int?) -> Unit,
onDismiss: () -> Unit,
description: String? = null,
inheritLabel: String? = null,
maxMinutes: Int = Int.MAX_VALUE,
) {
val customSelected = selected !in presets
val inherits = selected == null
// The current choice when it isn't one of the presets — the Custom row then
// names it and the editor opens pre-filled with it.
val custom = selected?.takeIf { it !in presets }
val rowCount = presets.size + 1 // + the custom row
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable {
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
mutableStateOf(custom?.let { durationCustomAmount(it).toString() }.orEmpty())
}
var unit by rememberSaveable {
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
mutableStateOf(custom?.let { durationCustomUnit(it) } ?: ReminderUnit.Minutes)
}
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
if (description != null) PickerDescription(description)
if (inheritLabel != null) {
GroupedRow(
title = inheritLabel,
position = Position.Alone,
selected = inherits,
trailing = if (inherits) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(null)
onDismiss()
},
)
Spacer(Modifier.height(24.dp))
}
presets.forEachIndexed { index, minute ->
val isSelected = minute == selected
GroupedRow(
@@ -403,10 +434,10 @@ fun SnoozeDurationPicker(
// The Custom row connects downward into the editor card when expanded, so
// the two read as one grouped container (the shared custom-expand pattern).
GroupedRow(
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
selected = customSelected,
trailing = if (customSelected) {
selected = custom != null,
trailing = if (custom != null) {
{ SelectedCheck() }
} else {
null
@@ -418,12 +449,13 @@ fun SnoozeDurationPicker(
enter = expandEnter(),
exit = collapseExit(),
) {
CustomSnoozeEditor(
CustomDurationEditor(
amountText = amountText,
onAmountChange = { amountText = it },
unit = unit,
onUnitChange = { unit = it },
label = label,
maxMinutes = maxMinutes,
onConfirm = { minutes ->
onSelect(minutes)
onDismiss()
@@ -433,41 +465,48 @@ fun SnoozeDurationPicker(
}
}
/** Whole hours if the delay divides evenly, else minutes. */
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
/** Whole hours if the length divides evenly, else minutes. */
private fun durationCustomUnit(minutes: Int): ReminderUnit =
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
private fun snoozeCustomAmount(minutes: Int): Int =
private fun durationCustomAmount(minutes: Int): Int =
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
/**
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
* The expanded "Custom" duration editor: a tonal card connected to the Custom row
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
* the delay it resolves to, and a tonal confirm enabled only for a valid 1999
* amount. [onConfirm] receives the final delay in minutes.
* the length it resolves to, and a tonal confirm enabled only for a valid 1999
* amount that stays within [maxMinutes]. Over the cap, the preview names the
* limit instead of a length the setting could not keep. [onConfirm] receives the
* final length in minutes.
*/
@Composable
private fun CustomSnoozeEditor(
private fun CustomDurationEditor(
amountText: String,
onAmountChange: (String) -> Unit,
unit: ReminderUnit,
onUnitChange: (ReminderUnit) -> Unit,
label: @Composable (Int) -> String,
maxMinutes: Int,
onConfirm: (Int) -> Unit,
) {
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
val entered = amountText.toIntOrNull()?.takeIf { it in 1..999 }?.times(unit.minutesFactor)
val amount = entered?.takeIf { it <= maxMinutes }
CustomAmountEditor(
amountText = amountText,
onAmountChange = onAmountChange,
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
onUnitChange = { onUnitChange(units[it]) },
preview = amount?.let { label(it * unit.minutesFactor) }
?: stringResource(R.string.reminder_custom_amount),
preview = when {
amount != null -> label(amount)
entered != null -> stringResource(R.string.duration_custom_max, label(maxMinutes))
else -> stringResource(R.string.reminder_custom_amount)
},
setLabel = stringResource(R.string.reminder_custom_set),
confirmEnabled = amount != null,
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
onConfirm = { amount?.let(onConfirm) },
)
}

View File

@@ -37,3 +37,21 @@ fun reminderLeadTimeLabel(minutes: Int): String = when {
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
}
/**
* Humanise a plain duration — no "before": "45 minutes", "8 hours", or both
* parts for a mixed length ("1 hour 30 minutes"). Shared by the snooze delay and
* the default event duration (#54).
*/
@Composable
fun durationLabel(minutes: Int): String {
val hours = minutes / 60
val rest = minutes % 60
val hoursLabel = pluralStringResource(R.plurals.duration_hours, hours, hours)
val minutesLabel = pluralStringResource(R.plurals.duration_minutes, rest, rest)
return when {
hours == 0 -> minutesLabel
rest == 0 -> hoursLabel
else -> stringResource(R.string.duration_hours_minutes, hoursLabel, minutesLabel)
}
}

View File

@@ -76,6 +76,18 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
.coerceAtLeast(fillHourHeight(viewportHeight))
}
/**
* The minute of the day a tap [offsetY] px down a timeline column means, snapped
* to the hour it landed in.
*
* [hourPx] must be the hour height the column is drawing at *now* — a tap
* detector that captured it when it was installed maps taps to a pre-pinch grid
* (#148). A zero or negative height (a viewport measured at nothing) has no grid
* to read, so it answers midnight rather than dividing by it.
*/
fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
if (hourPx <= 0f) 0 else (offsetY / hourPx).toInt().coerceIn(0, 23) * 60
/**
* Shortest an event block may render, as a fraction of an hour. Blocks keep a
* floor so a 15-minute event stays tappable, but the floor scales with the hour

View File

@@ -45,6 +45,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -68,6 +69,7 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
@@ -95,6 +97,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -225,6 +228,7 @@ fun DayScreen(
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
@@ -365,6 +369,7 @@ private fun DayTopBar(
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
@@ -372,9 +377,10 @@ private fun DayTopBar(
val locale = currentLocale()
TopAppBar(
title = {
Text(
text = formatDayTitle(date, locale, currentYear),
style = MaterialTheme.typography.titleLarge,
CalendarTitleButton(
title = formatDayTitle(date, locale, currentYear),
currentDate = date,
onJumpToDate = onJumpToDate,
)
},
navigationIcon = {
@@ -573,6 +579,12 @@ private fun DayColumnCard(
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
// The tap detector outlives the composition that installed it — a pinch or a
// Settings change moves the hour height without restarting it — so it reads
// the height and the callback through state handles instead of capturing
// them, or taps land on the scale the column had before the zoom (#148).
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
Card(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -593,8 +605,10 @@ private fun DayColumnCard(
// only fires on the column background. Snaps to the tapped hour.
.pointerInput(date) {
detectTapGestures { offset ->
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
onCreateAt(date, hour * 60)
currentOnCreateAt.value(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
)
}
},
) {

View File

@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
@@ -51,28 +52,38 @@ import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import javax.inject.Inject
/**
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
* sources want different reminder handling (#49).
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
* want different reminder handling (#49), and differ in whether they own the
* event's length ([ownsDuration], #54) — one that does is never restretched to the
* default-duration setting.
*/
enum class ImportSource {
enum class ImportSource(internal val ownsDuration: Boolean) {
/**
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
* It carries no reminders of its own, so the settings default is applied
* automatically, exactly like an in-app new event.
* An external `ACTION_INSERT` intent that named both ends (another app/widget,
* e.g. Google Maps), or an in-app duplicate. It carries no reminders of its
* own, so the settings default is applied automatically, exactly like an
* in-app new event; its span, though, is the caller's.
*/
Insert,
Insert(ownsDuration = true),
/**
* An `ACTION_INSERT` that named only a start. Reminders behave as for
* [Insert], but the length is ours to pick, so the default-duration setting
* applies just like an in-app new event.
*/
InsertOpenEnded(ownsDuration = false),
/**
* A parsed single-event `.ics` file. Its own reminders are respected; the
* settings default is offered through [EventEditViewModel.importReminderPrompt]
* rather than silently applied or suppressed.
* rather than silently applied or suppressed. Its span is the file's.
*/
File,
File(ownsDuration = true),
}
/**
@@ -113,6 +124,9 @@ class EventEditViewModel @Inject constructor(
// freezes the auto-applied default: switching calendars no longer overwrites
// their choice. Reset with the form.
private val _remindersTouched = MutableStateFlow(false)
// Same freeze for the default duration (#54): once the user has set an end
// time by hand, switching calendars no longer stretches the event.
private val _durationTouched = MutableStateFlow(false)
// A one-time offer, raised when a .ics import opens, to replace the file's
// reminders with the settings default (#49). Null while there's nothing to ask.
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
@@ -282,8 +296,10 @@ class EventEditViewModel @Inject constructor(
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
* from midnight) anchors the start when the form is opened by tapping a slot
* in the day/week grid; without it the default is the next full hour (today)
* or 09:00 (any other day). No-op when a form is already open, so user input
* survives configuration changes; [reset] clears it when the screen closes.
* or 09:00 (any other day). The event opens an hour long and is stretched to
* the configured default by [applyDefaultDuration], like the reminders.
* No-op when a form is already open, so user input survives configuration
* changes; [reset] clears it when the screen closes.
*/
fun openNew(date: LocalDate, startMinutes: Int? = null) {
if (_form.value != null) return
@@ -300,9 +316,11 @@ class EventEditViewModel @Inject constructor(
}
else -> LocalDateTime(date, LocalTime(9, 0))
}
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
.toLocalDateTime(zone)
_form.value = EventForm(calendarId = null, start = start, end = end)
applyDefaultReminder()
applyDefaultDuration()
}
/**
@@ -321,6 +339,12 @@ class EventEditViewModel @Inject constructor(
* settings default is configured and differs, [importReminderPrompt] offers
* to swap it in rather than silently deciding for the user.
*
* The length is frozen the same way: a source that named the event's end
* ([ImportSource.ownsDuration]) keeps it, so the default-duration setting
* never silently rewrites a span someone else chose; only an
* [ImportSource.InsertOpenEnded] intent — a start with no end — gets the
* default applied.
*
* No-op when a form is already open, so the prefill survives configuration
* changes.
*/
@@ -328,8 +352,9 @@ class EventEditViewModel @Inject constructor(
if (_form.value != null || _editTarget.value != null) return
_revealed.value = form.populatedFields()
_form.value = form
if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration()
when (source) {
ImportSource.Insert ->
ImportSource.Insert, ImportSource.InsertOpenEnded ->
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
else applyDefaultReminder()
@@ -358,7 +383,9 @@ class EventEditViewModel @Inject constructor(
val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch
val reminders = defaults.resolveFor(targetId, form.isAllDay)
_form.value = form.copy(reminders = reminders)
// Write through update(): the duration default resolves in parallel,
// so both must compose onto the current form, not a pre-suspend copy.
update { it.copy(reminders = reminders) }
// Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field.
if (reminders.isNotEmpty()) {
@@ -377,6 +404,39 @@ class EventEditViewModel @Inject constructor(
ReminderDefaults(timed, allDay, timedOv, allDayOv)
}.first()
/**
* Stretch a new timed event to the configured default length (#54) — the
* resolved calendar's per-calendar duration, otherwise the global default —
* keeping its start put. No-op while editing an existing event, on an all-day
* event (which is date-anchored), or once the user has set an end time by
* hand. [calendarId] short-circuits the resolution after a calendar switch;
* null resolves it as the form does.
*/
private fun applyDefaultDuration(calendarId: Long? = null) {
if (_editTarget.value != null || _durationTouched.value) return
viewModelScope.launch {
val global = settingsPrefs.defaultEventDurationMinutes.first()
val overrides = settingsPrefs.perCalendarEventDuration.first()
// Only resolve the calendar when an override could actually apply —
// that read waits on the provider query, and the stretch would then
// land after the user can already see (and accept) the end time.
val targetId = when {
overrides.isEmpty() -> null
else -> calendarId ?: resolvedCalendarId.first()
}
// Re-check after suspending: bail if the form closed or the user edited.
if (_editTarget.value != null || _durationTouched.value) return@launch
if (_form.value?.isAllDay != false) return@launch
val duration = resolveDefaultEventDuration(global, overrides, targetId)
val zone = TimeZone.currentSystemDefault()
update { form ->
form.copy(
end = (form.start.toInstant(zone) + duration.minutes).toLocalDateTime(zone),
)
}
}
}
/**
* A `.ics` import respects the file's reminders, but an event opened from a
* file often has none while the user still expects their configured default.
@@ -455,6 +515,7 @@ class EventEditViewModel @Inject constructor(
_editTarget.value = null
_loadFailed.value = false
_remindersTouched.value = false
_durationTouched.value = false
_importReminderPrompt.value = null
}
@@ -479,6 +540,9 @@ class EventEditViewModel @Inject constructor(
// The default reminder differs for all-day vs timed; re-apply the
// type-appropriate default unless the user has hand-edited it (guarded).
applyDefaultReminder()
// Coming back out of all-day re-applies the default length (guarded);
// going all-day is a no-op, since dates carry no duration.
applyDefaultDuration()
}
/**
@@ -500,9 +564,10 @@ class EventEditViewModel @Inject constructor(
*/
fun setCalendar(id: Long) {
update { it.copy(calendarId = id, colorKey = null, color = null) }
// A fresh event re-inherits the new calendar's default reminder unless
// the user has already hand-edited it (guarded inside).
// A fresh event re-inherits the new calendar's default reminder and
// length unless the user has already hand-edited them (guarded inside).
applyDefaultReminder(id)
applyDefaultDuration(id)
}
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
@@ -559,8 +624,18 @@ class EventEditViewModel @Inject constructor(
/** Moving the start drags the end along, preserving the duration. */
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
// Setting an end by hand is the user picking a length: it freezes the
// default duration, so a later calendar switch keeps their span (#54).
fun setEndDate(date: LocalDate) {
_durationTouched.value = true
update { it.copy(end = LocalDateTime(date, it.end.time)) }
}
fun setEndTime(time: LocalTime) {
_durationTouched.value = true
update { it.copy(end = LocalDateTime(it.end.date, time)) }
}
/**
* Validate and write. Saving a dirty recurring event pauses in

View File

@@ -101,6 +101,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
@@ -352,10 +353,12 @@ fun MonthScreen(
topBar = {
MonthTopBar(
title = topBarTitle,
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
@@ -511,19 +514,22 @@ private fun ContinuousMonthContent(
@Composable
private fun MonthTopBar(
title: String,
titleDate: LocalDate,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
CalendarTitleButton(
title = title,
currentDate = titleDate,
onJumpToDate = onJumpToDate,
)
},
navigationIcon = {

View File

@@ -1,24 +1,39 @@
package de.jeanlucmakiola.calendula.ui.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
import de.jeanlucmakiola.calendula.ui.common.durationLabel
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
/** New event form: which fields it opens with, and how it behaves. */
@Composable
@@ -27,6 +42,13 @@ internal fun EventFormScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
var showDefaultDuration by remember { mutableStateOf(false) }
// The calendar whose duration-override picker is open, if any.
var durationTarget by remember { mutableStateOf<Long?>(null) }
var durationSectionExpanded by remember { mutableStateOf(false) }
// Special-dates calendars carry all-day events only, which have no length.
val durationCalendars = state.writableCalendars.filterNot { it.id in state.managedCalendarIds }
CollapsingScaffold(
title = stringResource(R.string.settings_section_event_form),
onBack = onBack,
@@ -80,6 +102,64 @@ internal fun EventFormScreen(
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
)
// How long a new timed event opens (#54), globally and per calendar —
// the per-calendar list folds behind its own header, like the reminders.
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_event_duration),
summary = durationLabel(state.defaultEventDurationMinutes),
position = if (durationCalendars.isEmpty()) Position.Alone else Position.Top,
leading = {
Icon(
imageVector = Icons.Default.Schedule,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = { showDefaultDuration = true },
)
if (durationCalendars.isNotEmpty()) {
GroupedRow(
title = stringResource(R.string.settings_calendar_durations_title),
summary = stringResource(R.string.settings_calendar_durations_hint),
// Expanded, the header opens the calendar run below it and the
// two read as one container (the shared expand pattern).
position = if (durationSectionExpanded) Position.Top else Position.Bottom,
trailing = {
Icon(
imageVector = if (durationSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = { durationSectionExpanded = !durationSectionExpanded },
)
AnimatedVisibility(
visible = durationSectionExpanded,
enter = expandEnter(),
exit = collapseExit(),
) {
Column {
durationCalendars.forEachIndexed { index, calendar ->
val override = state.perCalendarEventDuration[calendar.id]
GroupedRow(
title = calendar.displayName,
summary = override?.let { durationLabel(it) }
?: stringResource(
R.string.settings_calendar_duration_inherits,
durationLabel(state.defaultEventDurationMinutes),
),
// The header above is the run's first row, so the
// calendars continue it: middles, then its bottom.
position = positionOf(index + 1, durationCalendars.size + 1),
leading = { CalendarColorChip(calendar.color) },
onClick = { durationTarget = calendar.id },
)
}
}
}
}
// Per-event colour on calendars that publish no colour set (some
// CalDAV); off by default, since it may not survive their next sync.
Spacer(Modifier.height(24.dp))
@@ -100,4 +180,36 @@ internal fun EventFormScreen(
},
)
}
if (showDefaultDuration) {
DurationPicker(
title = stringResource(R.string.settings_event_duration),
description = stringResource(R.string.settings_event_duration_hint),
presets = EVENT_DURATION_PRESETS,
selected = state.defaultEventDurationMinutes,
label = { durationLabel(it) },
// No inherit row on the global default, so a pick is never null.
onSelect = { minutes -> minutes?.let { viewModel.setDefaultEventDuration(it) } },
onDismiss = { showDefaultDuration = false },
maxMinutes = SettingsPrefs.MAX_EVENT_DURATION,
)
}
durationTarget?.let { calendarId ->
DurationPicker(
title = stringResource(R.string.settings_event_duration),
presets = EVENT_DURATION_PRESETS,
selected = state.perCalendarEventDuration[calendarId],
label = { durationLabel(it) },
inheritLabel = stringResource(
R.string.settings_calendar_duration_use_default,
durationLabel(state.defaultEventDurationMinutes),
),
onSelect = { viewModel.setCalendarEventDuration(calendarId, it) },
onDismiss = { durationTarget = null },
maxMinutes = SettingsPrefs.MAX_EVENT_DURATION,
)
}
}
/** Lengths offered for a new timed event, in minutes — up to a full work day. */
private val EVENT_DURATION_PRESETS = listOf(15, 30, 45, 60, 90, 120, 240, 480)

View File

@@ -28,7 +28,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -38,10 +37,11 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.calendula.ui.common.durationLabel
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
@@ -146,7 +146,7 @@ internal fun NotificationsScreen(
GroupedRow(
title = stringResource(R.string.settings_snooze_duration),
summary = snoozeDurationLabel(state.snoozeMinutes),
summary = durationLabel(state.snoozeMinutes),
position = Position.Bottom,
onClick = { showSnooze = true },
)
@@ -245,12 +245,13 @@ internal fun NotificationsScreen(
}
if (showSnooze) {
SnoozeDurationPicker(
DurationPicker(
title = stringResource(R.string.settings_snooze_duration),
presets = SNOOZE_PRESETS,
selected = state.snoozeMinutes,
label = { snoozeDurationLabel(it) },
onSelect = { viewModel.setSnoozeMinutes(it) },
label = { durationLabel(it) },
// No inherit row here, so a pick is never null.
onSelect = { minutes -> minutes?.let { viewModel.setSnoozeMinutes(it) } },
onDismiss = { showSnooze = false },
)
}
@@ -348,15 +349,6 @@ private fun calendarOverrideSummary(
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
@Composable
private fun snoozeDurationLabel(minutes: Int): String =
if (minutes % 60 == 0) {
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
} else {
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
}
/**
* Whether Calendula is exempt from battery optimisation, re-read on every
* `ON_RESUME` so a change made in system settings shows up at once.

View File

@@ -66,6 +66,10 @@ data class SettingsUiState(
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
val autofocusEventTitle: Boolean = true,
/** How long a new timed event lasts, in minutes (#54); all-day events ignore it. */
val defaultEventDurationMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION,
/** Per-calendar overrides of [defaultEventDurationMinutes]; absent = inherit. */
val perCalendarEventDuration: Map<Long, Int> = emptyMap(),
/** Whether Calendula posts reminder notifications (v1.4). */
val remindersEnabled: Boolean = true,
/**

View File

@@ -124,8 +124,14 @@ class SettingsViewModel @Inject constructor(
prefs.perCalendarAllDayReminderOverride,
writableCalendars,
prefs.managedCalendarIds,
) { overrides, allDayOverrides, calendars, managedIds ->
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
// The new-event duration defaults (#54) fold into one flow so they
// fit this group — the outer combine is at its five-arg limit.
combine(
prefs.defaultEventDurationMinutes,
prefs.perCalendarEventDuration,
) { duration, perCalendar -> EventDurations(duration, perCalendar) },
) { overrides, allDayOverrides, calendars, managedIds, durations ->
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds, durations)
},
combine(
prefs.defaultView,
@@ -201,6 +207,8 @@ class SettingsViewModel @Inject constructor(
perCalendarAllDayReminderOverride = overrides.allDay,
writableCalendars = overrides.calendars,
managedCalendarIds = overrides.managedIds,
defaultEventDurationMinutes = overrides.durations.default,
perCalendarEventDuration = overrides.durations.perCalendar,
)
}.stateIn(
scope = viewModelScope,
@@ -263,6 +271,12 @@ class SettingsViewModel @Inject constructor(
val allDay: Map<Long, List<Int>>,
val calendars: List<CalendarSource>,
val managedIds: Set<Long>,
val durations: EventDurations,
)
private data class EventDurations(
val default: Int,
val perCalendar: Map<Long, Int>,
)
private data class ViewSettings(
@@ -608,6 +622,15 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
}
fun setDefaultEventDuration(minutes: Int) {
viewModelScope.launch { prefs.setDefaultEventDurationMinutes(minutes) }
}
/** [minutes] null clears the override, so the calendar inherits the default. */
fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
viewModelScope.launch { prefs.setCalendarEventDuration(calendarId, minutes) }
}
fun setDefaultReminderMinutes(minutes: List<Int>) {
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
}

View File

@@ -51,6 +51,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -77,6 +78,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
@@ -106,6 +108,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.time.isoWeekNumber
@@ -246,6 +249,7 @@ fun WeekScreen(
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
@@ -398,6 +402,7 @@ private fun WeekTopBar(
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
@@ -405,9 +410,10 @@ private fun WeekTopBar(
val locale = currentLocale()
TopAppBar(
title = {
Text(
text = formatWeekTitle(weekStart, locale, currentYear),
style = MaterialTheme.typography.titleLarge,
CalendarTitleButton(
title = formatWeekTitle(weekStart, locale, currentYear),
currentDate = weekStart,
onJumpToDate = onJumpToDate,
)
},
navigationIcon = {
@@ -718,6 +724,12 @@ private fun DayColumnCard(
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
// The tap detector outlives the composition that installed it — a pinch or a
// Settings change moves the hour height without restarting it — so it reads
// the height and the callback through state handles instead of capturing
// them, or taps land on the scale the column had before the zoom (#148).
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -737,8 +749,10 @@ private fun DayColumnCard(
// blocks are consumed by their own handler first. Snaps to hour.
.pointerInput(date) {
detectTapGestures { offset ->
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
onCreateAt(date, hour * 60)
currentOnCreateAt.value(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
)
}
},
) {

View File

@@ -1,12 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Monochrome status-bar mark: Material "event" calendar glyph. -->
<!--
Status-bar mark for reminders (issue #83). Calendula's own mark, not the
stock Material "event" glyph it replaces: the status bar draws a small icon
from its alpha channel alone, so the silhouette is the only thing that can
tell Calendula apart from the system's date surfaces and every other
calendar app.
Redrawn for 24dp from design/icon/calendula_notification.svg rather than
scaled down from the launcher mark — the numeral "1" is dropped and the
bloom's petals are filled instead of stroked, both of which turn to mush at
this size. Stays opaque white on transparent; any colour here is discarded.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
android:viewportHeight="24">
<!-- Calendar body, left open at the bottom-right where the bloom sits -->
<path
android:fillColor="@android:color/white"
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19zM7,10h5v5H7V10z" />
android:strokeColor="#FFFFFFFF"
android:strokeWidth="1.9"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M1.4,5.4H18.6M18.6,9.43V4.7C18.6,2.99 17.21,1.6 15.5,1.6H4.5C2.79,1.6 1.4,2.99 1.4,4.7V15.7C1.4,17.41 2.79,18.8 4.5,18.8H9.63" />
<!-- Calendula bloom: eight filled petals around a filled centre -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M17.6,16.35Q20.42,18.65 23.25,16.35Q20.42,14.05 17.6,16.35Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M17.26,17.16Q17.63,20.79 21.26,21.16Q20.89,17.53 17.26,17.16Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M16.45,17.5Q14.15,20.33 16.45,23.15Q18.75,20.33 16.45,17.5Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M15.64,17.16Q12.01,17.53 11.64,21.16Q15.27,20.79 15.64,17.16Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M15.3,16.35Q12.47,14.05 9.65,16.35Q12.47,18.65 15.3,16.35Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M15.64,15.54Q15.27,11.91 11.64,11.54Q12.01,15.17 15.64,15.54Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M16.45,15.2Q18.75,12.38 16.45,9.55Q14.15,12.38 16.45,15.2Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M17.26,15.54Q20.89,15.17 21.26,11.54Q17.63,11.91 17.26,15.54Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M16.45,13.85A2.5,2.5 0 1,1 16.45,18.85A2.5,2.5 0 1,1 16.45,13.85Z" />
</vector>

View File

@@ -32,7 +32,7 @@
<string name="month_prev">الشهر السابق</string>
<string name="month_next">الشهر القادم</string>
<string name="month_today_action">اليوم</string>
<string name="month_action_settings">إعدادات</string>
<string name="month_action_settings">الإعدادات</string>
<string name="settings_title">الإعدادات</string>
<string name="settings_theme">الثيم</string>
<string name="settings_theme_system">النظام</string>
@@ -137,7 +137,7 @@
<string name="event_detail_calendar">التقويم</string>
<string name="event_detail_calendar_unknown">تقويم غير معروف</string>
<string name="event_detail_description">الوصف</string>
<string name="event_detail_all_day">كل يوم</string>
<string name="event_detail_all_day">طوال اليوم</string>
<string name="event_detail_location">الموقع</string>
<string name="event_detail_attendees">الحضور</string>
<string name="event_detail_recurrence">التكرار</string>
@@ -219,8 +219,8 @@
<string name="settings_dynamic_color">اللون الديناميكي</string>
<string name="settings_dynamic_color_unavailable">يتطلب أندرويد ١٢ أو أحدث</string>
<string name="settings_default_view">طريقة العرض الافتراضية</string>
<string name="settings_soften_colors">ألوان تقويم ناعمة</string>
<string name="settings_soften_colors_summary">خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
<string name="settings_soften_colors">ألوان تقويم متناسقة</string>
<string name="settings_soften_colors_summary">حافظ على درجة لون كل تقويم لكن قم بتسوية سطوعه، بحيث كل حدث يظل مقروءًا و تتناسق الألوان معًا. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
<string name="settings_font_headings">خط العناوين</string>
<string name="settings_font_body">خط النص</string>
<string name="settings_font_system">افتراضي النظام</string>
@@ -309,4 +309,237 @@
<string name="agenda_range_showing_label">إظهار جميع الأحداث القادمة لـ</string>
<string name="settings_reminders">تذكيرات الحدث</string>
<string name="settings_default_reminder">التذكير الافتراضي</string>
<string name="reminder_custom_set">تعيين</string>
<string name="settings_autofocus_title_hint">عند بدء حدث جديد، ضع المؤشر في حقل العنوان وافتح لوحة المفاتيح على الفور.</string>
<string name="settings_form_fields_hint">الخانات المعروضة افتراضيًا — كل شيء آخر موجود ضمن \"المزيد من الخانات\"</string>
<string name="settings_section_event_form">نموذج حدث جديد</string>
<string name="settings_quick_switch_header">زر التبديل السريع</string>
<string name="app_name">التقويم</string>
<string name="settings_theme_system_summary">الحالي %1$s</string>
<string name="settings_theme_hint">سواء التطبيق فاتحًا أو داكنًا. الاختيار يُطبق على الفور.</string>
<string name="settings_week_start_auto_summary">الحالي %1$s</string>
<string name="settings_time_format_auto_summary">اتباع النظام: %1$s</string>
<string name="settings_hour_lines">خطوط الساعات</string>
<string name="settings_hour_lines_summary">إظهار خط فاصل في كل ساعة في طريقة عرض الأسبوع واليوم</string>
<string name="timeline_scale_fit_day_summary">جميع الـ 24 ساعة على شاشة واحدة، لا تمرير</string>
<string name="timeline_scale_custom">مخصص</string>
<string name="settings_widget_size_small">صغير</string>
<string name="settings_widget_size_medium">متوسط</string>
<string name="settings_widget_size_large">كبير</string>
<string name="settings_widget_size_extra_large">كبير جدًا</string>
<plurals name="agenda_range_days">
<item quantity="zero">(%d) لا أيام</item>
<item quantity="one">(%d) يوم واحد</item>
<item quantity="two">%d يومان</item>
<item quantity="few">%d أيام</item>
<item quantity="many">%d يوم</item>
<item quantity="other">%d يوم</item>
</plurals>
<string name="settings_agenda_range_bar_hint">اعرض شريطًا في الجزء العلوي من الجدول يحدد التواريخ المعروضة، مع زر لتبديل النطاق للجلسة</string>
<string name="settings_agenda_range_bar">شريط النطاق</string>
<string name="settings_month_header">عرض الشهر</string>
<string name="month_style_continuous">تمرير الشهور</string>
<string name="month_style_dense">أسابيع سلسة</string>
<string name="month_style_continuous_summary">كل شهر يقع تحت عنوانه الخاص، مع مساحة صغيرة تميزه عن الشهر التالي.</string>
<string name="month_style_dense_summary">تمر الأسابيع دون انقطاع، ويتدفق كل شهر مباشرة إلى الشهر التالي دون وجود فجوة بينهما.</string>
<string name="month_split_no_events">لا شيء مجدول</string>
<string name="month_style_split_summary">شبكة مدمجة تحدد الأيام التي تتضمن أحداثًا، ويتم إدراج اليوم الذي تنقر عليه تحتها.</string>
<string name="settings_autofocus_title">تركيز العنوان على حدث جديد</string>
<string name="settings_event_duration">المدة الافتراضية</string>
<string name="settings_event_duration_hint">مدة الحدث الجديد تبقى كما هي حتى أنت تغيير وقت نهايته. أما أحداث التي هي طوال اليوم فلن تتأثر.</string>
<string name="settings_section_notifications">الإشعارات</string>
<string name="settings_reminders_hint">ترى التذكيرات مرتين؟ هناك تطبيق تقويم آخر ينشرهم أيضًا — قم بإيقافهم في أحد من الاثنين.</string>
<string name="settings_group_look">المظهر و السلوك</string>
<string name="settings_group_data">البيانات</string>
<string name="settings_group_app">التطبيق</string>
<string name="settings_language">لغة التطبيق</string>
<string name="settings_dynamic_color_summary">أخذ ألوان التطبيق من خلفيتك.</string>
<string name="settings_group_about">حول</string>
<string name="settings_about_author">بواسطة Jean-Luc Makiola</string>
<string name="settings_about_source">المصدر</string>
<string name="settings_about_privacy">سياسة الخصوصية</string>
<string name="settings_about_support">دَعم التطوير</string>
<string name="settings_about_version">الإصدار %1$s</string>
<string name="settings_about_logo_desc">رمز تطبيق Calendula</string>
<string name="crash_report_body_template">شكرًا لإبلاغ عن عطل في %1$s. يُرجى إضافة أي شيء تتذكره حول ما كنت تفعله، ثم إرسال.\n\n### ماذا حدث\n\n\n### تقرير العطل\n%2$s\n</string>
<string name="settings_section_about">حول</string>
<string name="settings_report_problem">الإبلاغ عن مشكلة</string>
<string name="settings_report_problem_hint">أرسل تقرير الأعطال أو افتح متعقب المشكلات</string>
<string name="settings_language_auto">افتراضي النظام</string>
<string name="settings_section_backup">النسخ الاحتياطي والاستعادة</string>
<string name="settings_special_dates_enable">إظهار تواريخ جهات الاتصال</string>
<string name="settings_special_dates_enable_hint">اعكس أعياد ميلاد جهات اتصالك والتواريخ الأخرى إلى التقويمات المحلية. يقرأ جهات الاتصال على هذا الجهاز فقط — لا يتم رفع أي شئ، وجهات اتصالك لا يتم تغييرها أبدًا.</string>
<string name="settings_section_language">اللغة</string>
<string name="qs_tile_new_event_label">حدث جديد</string>
<string name="shortcut_new_event_long">إنشاء حدث جديد</string>
<string name="shortcut_new_event_short">حدث جديد</string>
<string name="settings_qs_tile_hint">أضف مربع ”حدث جديد“ إلى لوحة الإعدادات السريعة.</string>
<string name="calendars_local_header">تقويماتك</string>
<string name="settings_section_special_dates">تواريخ جهات الاتصال الخاصة</string>
<string name="calendars_title">التقويمات</string>
<string name="calendars_add">إضافة تقويم</string>
<string name="settings_section_calendars">التقويمات</string>
<string name="settings_manage_calendars">إدارة التقويمات</string>
<string name="event_edit_recurrence_next">التالي: %1$s</string>
<plurals name="reminder_minutes">
<item quantity="zero">(%d) لا دقائق قبل</item>
<item quantity="one">(%d) دقيقة واحده قبل</item>
<item quantity="two">%d دقيقتان قبل</item>
<item quantity="few">%d دقائق قبل</item>
<item quantity="many">%d دقيقة قبل</item>
<item quantity="other">%d دقيقة قبل</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="zero">(%d) لا ساعات قبل</item>
<item quantity="one">(%d) ساعة واحده قبل</item>
<item quantity="two">%d ساعتان قبل</item>
<item quantity="few">%d ساعات قبل</item>
<item quantity="many">%d ساعة قبل</item>
<item quantity="other">%d ساعة قبل</item>
</plurals>
<plurals name="reminder_days">
<item quantity="zero">(%d) لا أيام قبل</item>
<item quantity="one">(%d) يوم واحد قبل</item>
<item quantity="two">%d يومان قبل</item>
<item quantity="few">%d أيام قبل</item>
<item quantity="many">%d يوم قبل</item>
<item quantity="other">%d يوم قبل</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="zero">(%d) لا أسابيع قبل</item>
<item quantity="one">(%d) أسبوع واحد قبل</item>
<item quantity="two">%d أسبوعان قبل</item>
<item quantity="few">%d أسابيع قبل</item>
<item quantity="many">%d أسبوع قبل</item>
<item quantity="other">%d أسبوع قبل</item>
</plurals>
<string name="reminder_benefit_reversible_body">يوجد المفتاح في الإعدادات، ضمن الإشعارات.</string>
<string name="reminder_custom_amount">القيمة</string>
<string name="settings_manage_calendars_hint">إنشاء تقويمات محلية؛ إدارة التقويمات المتزامنة</string>
<string name="settings_snooze_duration">مدة التأجيل</string>
<string name="settings_calendar_reminder_inherits">الافتراضي (%1$s)</string>
<string name="settings_translate">المساعدة في الترجمة</string>
<string name="settings_translate_hint">إضافة أو تحسين لغة على Weblate</string>
<string name="settings_appearance_subtitle">الثيم، الألوان، الخطوط</string>
<string name="settings_views_subtitle">العرض، التخطيط، الترتيب</string>
<string name="settings_event_form_subtitle">الخانات الافتراضية و السلوك</string>
<string name="settings_notifications_subtitle">التذكيرات و التسليم</string>
<string name="settings_special_dates_subtitle">أعياد ميلاد جهات الاتصال و احتفالات ذكرى</string>
<string name="settings_special_dates_type_birthday">أعياد الميلاد</string>
<string name="settings_special_dates_type_custom">تواريخ أخرى</string>
<string name="settings_license">الترخيص</string>
<string name="settings_special_dates_disable_title">إيقاف تشغيل تواريخ جهات الاتصال؟</string>
<string name="settings_special_dates_disable_confirm">إيقاف</string>
<string name="settings_special_dates_sync_now">زامن الآن</string>
<string name="settings_special_dates_never_synced">لم تتم المزامنة بعد</string>
<string name="settings_special_dates_paused_hint">لم يعد بإمكان Calendula قراءة جهات الاتصال الخاصة بك، لذلك لا يتم تحديث هذه التقويمات.</string>
<string name="calendars_manage_in_app">الإدارة في التطبيق</string>
<string name="calendars_enable_all">تفعيل الكل</string>
<string name="calendars_disable_all">تعطيل الكل</string>
<string name="calendars_add_account">إضافة حساب</string>
<string name="calendars_new_title">تقويم جديد</string>
<string name="calendars_edit_title">تعديل التقويم</string>
<string name="calendars_name_label">الاسم</string>
<string name="calendars_color_label">اللون</string>
<string name="calendars_description_hint">أضف وصفًا</string>
<string name="calendars_delete_confirm_title">حذف التقويم؟</string>
<string name="calendars_delete_confirm_message">\"%1$s\" وجميع أحداثه ستتم إزالتها نهائيًا من هذا الجهاز.</string>
<string name="calendars_write_error">تعذّر حفظ التغيير.</string>
<string name="calendars_backup_header">النسخ الاحتياطي</string>
<string name="calendars_backup_hint">التقويمات المحلية لا تتم مزامنتها في أي مكان، لذا قم بتصديرها إلى ملف .ics للاحتفاظ بنسخة.</string>
<string name="dialog_save">حفظ</string>
<string name="settings_special_dates_grant">منح الوصول</string>
<string name="calendars_backup_action">تصدير كملف .ics</string>
<string name="calendars_export_title">تصدير التقويمات</string>
<string name="calendars_export_hint">اختر التقويمات التي تريد تضمينها في .ics الملف.</string>
<string name="calendars_export_action">تصدير</string>
<string name="calendars_restore_header">استعادة</string>
<string name="calendars_restore_action">استعادة من ملف .ics</string>
<string name="calendars_restore_hint">استيراد الأحداث من نسخة احتياطية أو تطبيق تقويم آخر.</string>
<string name="calendars_auto_backup">النسخ الاحتياطي التلقائي</string>
<string name="calendars_auto_backup_hint">قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ .ics الملف.</string>
<string name="calendars_auto_backup_folder">مجلد النسخ الاحتياطي</string>
<string name="calendars_auto_backup_folder_unset">اضغط لاختيار مجلد</string>
<string name="calendars_auto_backup_every">كل %1$s</string>
<string name="calendars_auto_backup_interval_min">الحد الأدني ٣٠ دقيقة.</string>
<string name="calendars_auto_backup_status_never">لا نسخ احتياطي تلقائي بعد</string>
<string name="calendars_auto_backup_status_ok">آخر نسخة احتياطية: %1$s</string>
<string name="calendars_auto_backup_status_failed">فشل آخر نسخ احتياطي: %1$s</string>
<string name="backup_channel_name">النسخ الاحتياطي</string>
<string name="backup_channel_description">يحذّر إذا تفشل النسخ الاحتياطي التلقائي بشكل متكرر.</string>
<string name="backup_failed_title">فشل النسخ الاحتياطي التلقائي</string>
<string name="backup_failed_text">Calendula تعذَّر في كتابة ملف النسخ الاحتياطي. تحقق من مجلد النسخ الاحتياطي في الإعدادات.</string>
<string name="calendars_backup_failed">تعذّر تصدير النسخة الاحتياطية.</string>
<plurals name="calendars_backup_done">
<item quantity="zero">تم تصدير (%d) لا أحداث.</item>
<item quantity="one">تم تصدير (%d) حدث واحد.</item>
<item quantity="two">تم تصدير %d حدثان.</item>
<item quantity="few">تم تصدير %d أحداث.</item>
<item quantity="many">تم تصدير %d حدث.</item>
<item quantity="other">تم تصدير %d حدث.</item>
</plurals>
<string name="import_title">استيراد الأحداث</string>
<string name="import_target_header">إضافة إلى تقويم</string>
<string name="import_empty">لم يتم العثور على أحداث في هذا الملف.</string>
<string name="import_failed">تعذَّر قراءة هذا الملف.</string>
<string name="import_no_calendar">لا تقويم قابل للكتابة لاستيراد إليه. أنشئ تقويمًا محليًا أولاً.</string>
<string name="import_done_title">اكتمل الاستيراد</string>
<string name="import_done_dedup_note">تم تخطي الأحداث الموجودة بالفعل في التقويم.</string>
<string name="settings_special_dates_reminders">التذكيرات</string>
<string name="calendars_account_menu_a11y">المزيد من الخيارات لـ %1$s</string>
<string name="calendars_synced_hint">تأتي هذه من الحسابات الموجودة على جهازك. يمكنك إنشاؤها وتعديلها في تطبيقها الخاص.</string>
<string name="calendars_synced_header">التقويمات المتزامنة</string>
<string name="calendars_local_empty">لا تقويمات محلية حتى الآن. أنشئ واحدًا للاحتفاظ بالأحداث على هذا الجهاز فقط.</string>
<string name="calendars_auto_backup_interval">الفاصل الزمني</string>
<string name="import_warning_no_start">تم تخطي حدث بدون وقت بدء.</string>
<string name="import_warning_recurrence">تم تخطي بعض التكرارات التي تم تغييرها للأحداث المتكررة.</string>
<string name="import_close">إغلاق</string>
<string name="import_done_skipped_label">التكرارات</string>
<string name="import_done_added_label">أُضيف</string>
<string name="crash_dialog_title">%1$s تعطل</string>
<string name="settings_qs_tile">إضافة مربع الإعدادات السريعة</string>
<string name="import_button">استيراد</string>
<string name="crash_dialog_dismiss">ليس الآن</string>
<string name="crash_report_copied">تم نسخ التقرير إلى الحافظة</string>
<string name="crash_report_open_failed">تعذّر فتح متتبع المشكلات. التقرير موجود في الحافظة الخاصة بك.</string>
<string name="crash_report_issue_title">تقرير العطل</string>
<string name="crash_report_clip_label">%1$s تقرير العطل</string>
<string name="crash_report_body_paste">_(كان التقرير طويلًا جدًا لهذا الرابط — الصقه من الحافظة هنا.)_</string>
<string name="special_dates_calendar_birthday">أعياد الميلاد</string>
<string name="special_dates_calendar_custom">التواريخ الخاصة</string>
<string name="event_edit_recurrence_next_none">هذه القاعدة لا تتكرر أبدًا</string>
<string name="event_access_public_summary">يري كل شخص لديه حق الوصول التفاصيل الكاملة</string>
<string name="event_access_private_summary">الآخرون يرون فقط أنك مشغول</string>
<string name="event_access_default_summary">أينما كان ما يفعله هذا التقويم عادةً</string>
<string name="month_split_collapse">عرض أحداث اليوم</string>
<string name="month_split_expand">إظهار الشهر بأكمله</string>
<string name="settings_week_day_header">الأسبوع و اليوم</string>
<string name="settings_default_view_hint">طريقة العرض التي يفتحها Calendula عندما تقوم ببدءه.</string>
<string name="settings_week_start_hint">اليوم الذي يبدأ به كل أسبوع، في جميع طرق العرض والودجتز.</string>
<string name="settings_time_format_hint">كيف الأوقات تُكتب في جميع أنحاء التطبيق. تلقائيًا يتبع إعداد نظامك.</string>
<string name="settings_past_events_hint">ما الذي يفعله جدول بالأحداث التي انتهت بالفعل.</string>
<string name="calendars_visibility_a11y">إظهار \"%1$s\"</string>
<string name="calendars_visibility_notice_title">بعض التقويمات متوقفة</string>
<string name="calendars_visibility_notice_message">Calendula يعرض الآن التقويمات التي تم تشغيلها لهذا الجهاز، لذلك ما تراه وما يذكرك لم يعد بإمكانه أن يختلف. بعض تقويماتك متوقفة حاليًا — تم إيقافها هنا أو في تطبيق تقويم آخر. أعد تشغيل أي منها في الإعدادات ← التقويمات.</string>
<string name="calendar_picker_missing_title">تفتقد تقويمًا؟</string>
<string name="calendar_picker_missing_summary">قد يكون متوقفًا عن التشغيل، للقراءة فقط أو مملوءًا من جهات الاتصال الخاصة بك — يمكنك إدارة تقويماتك هنا.</string>
<string name="calendars_state_read_only">للقراءة فقط</string>
<string name="calendars_state_not_synced">لم تتم المزامنة مع هذا الجهاز</string>
<string name="calendars_state_managed">تم ملؤه من جهات اتصالك</string>
<string name="calendars_managed_delete_locked">تم ملء هذا التقويم من جهات الاتصال الخاصة بك، لذلك سيقوم Calendula بإنشائه مرة أخرى في المزامنة التالية. قم بإيقاف تشغيل التواريخ الخاصة ضمن الإعدادات ← التواريخ الخاصة لحذفها.</string>
<string name="duration_custom_max">كحد أقصى %1$s</string>
<string name="settings_calendar_duration_inherits">الافتراضي (%1$s)</string>
<string name="settings_calendar_duration_use_default">استخدام المدة الافتراضية (%1$s)</string>
<string name="reminder_benefit_delivery_title">التذكيرات، تُسلَّم</string>
<string name="reminder_use_default">استخدام التذكير الافتراضي</string>
<string name="settings_timeline_scale">ارتفاع الساعة</string>
<string name="settings_timeline_scale_hint">كم مدى المساحة العمودية التي تأخذها الساعة الواحده في عرض الأسبوع واليوم. كلا العرضين يشتركا في هذا الإعداد. يمكنك أيضًا الضغط على المخطط الزمني بإصبعين لتعيين أي ارتفاع بين.</string>
<string name="timeline_scale_fit_day">الملائمة لليوم بأكمله</string>
<string name="timeline_scale_compact">مضغوط</string>
<string name="timeline_scale_regular_summary">المساحة القياسية</string>
<string name="timeline_scale_comfortable">مُريَّح</string>
<string name="timeline_scale_compact_summary">المزيد من الساعات لكل شاشة، ومجموعات أصغر</string>
<string name="timeline_scale_regular">العادي</string>
<string name="timeline_scale_comfortable_summary">مجموعات أوسع، تمرير أكثر</string>
<string name="timeline_scale_custom_summary">الارتفاع الذي قمت بضغظت المخطط الزمني إليه</string>
</resources>

View File

@@ -491,4 +491,5 @@
<item quantity="many">Importando %d eventos</item>
<item quantity="other">Importando %d eventos</item>
</plurals>
<string name="settings_soften_colors">Armoniza los colores del calendario</string>
</resources>

View File

@@ -44,7 +44,7 @@
<string name="event_detail_share">Partager</string>
<string name="event_share_chooser_title">Evénement partagé</string>
<string name="event_share_failed">Impossible de partager cet événement.</string>
<string name="event_delete_title">Evénement supprimé?</string>
<string name="event_delete_title">Evénement supprimé ?</string>
<string name="event_delete_body">Cet événement est retiré de votre calendrier et de chaque appareil auquel il est synchronisé.</string>
<string name="event_delete_recurring_title">Supprimer l\'événement récurrent</string>
<string name="event_delete_option_occurrence">Seulement cet événement</string>
@@ -95,14 +95,14 @@
<string name="event_edit_color_unsupported_hint">Ce calendrier ne propose aucun ensemble de couleurs. Vous pouvez autoriser des couleurs personnalisées pour ces calendriers dans les paramètres.</string>
<string name="event_edit_color_sync_warning">Ce calendrier pourrait retirer ou remplacer la couleur lors de sa prochaine synchronisation.</string>
<string name="event_edit_conflict_title">L\'événement a changé ailleurs</string>
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications?</string>
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
<string name="event_edit_conflict_overwrite">Enregistrer mes modifications</string>
<string name="event_edit_conflict_overwrite_hint">Seuls les champs que vous modifiez remplacent l\'altération externe</string>
<string name="event_edit_conflict_discard">Annuler mes modifications</string>
<string name="event_edit_conflict_discard_hint">Lévénement reste tel quil est maintenant</string>
<string name="event_edit_gone_title">Evénement supprimé</string>
<string name="event_edit_gone_body">Cet événement a été supprimé entre-temps, par exemple sur un autre appareil. Vos modifications ne peuvent plus être enregistrées.</string>
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut?</string>
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
<string name="import_reminder_prompt_body_none">Cet événement a été importé sans aucun rappel.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">Cet événement a été importé avec %1$d rappel.</item>
@@ -178,7 +178,7 @@
<string name="event_access_confidential">Confidentiel</string>
<string name="event_attendee_organizer">Organisateur</string>
<string name="event_attendee_resource">Ressource</string>
<string name="event_detail_self_response">Votre réponse : %1$s</string>
<string name="event_detail_self_response">Votre réponse : %1$s</string>
<string name="reminder_default">Rappel par défaut</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minute avant</item>
@@ -283,7 +283,7 @@
<string name="event_status_tentative">Provisoire</string>
<string name="reminder_at_time">Au moment de lévénement</string>
<string name="reminder_onboarding_body">Android naffiche pas de rappels dévénements par lui-même — une application de calendrier doit le faire. Laissez Calendula faire ce travail.</string>
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier?</string>
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier ?</string>
<string name="reminder_benefit_duplicates_body">Si une autre application publie également des rappels, vous les verrez deux fois, désactivez-les là ou ici.</string>
<string name="reminder_benefit_reversible_title">modifier à tout moment</string>
<string name="reminder_benefit_reversible_body">Le commutateur se trouve dans les paramètres, sous Notifications.</string>
@@ -331,14 +331,14 @@
<string name="settings_drawer_order_hint">Faites glisser pour réorganiser les vues répertoriées dans le menu de navigation.</string>
<string name="reorder_drag_handle">Faites glisser pour réorganiser</string>
<string name="settings_section_event_form">Nouveau formulaire dévénement</string>
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
<string name="settings_autofocus_title">Titre principal du nouvel événement</string>
<string name="settings_autofocus_title_hint">Lorsque vous démarrez un nouvel événement, placez le curseur dans le champ du titre et ouvrez immédiatement le clavier.</string>
<string name="settings_color_unsupported">Autoriser les couleurs dans les calendriers non pris en charge</string>
<string name="settings_color_unsupported_hint">Certains calendriers (par exemple, certains CalDAV) ne publient aucun ensemble de couleurs, une couleur dévénement personnalisée peut être supprimée ou remplacée lors de leur prochaine synchronisation. Cest une limitation de ces calendriers, pas quelque chose que Calendula peut réparer.</string>
<string name="settings_section_notifications">Notifications</string>
<string name="settings_reminders">rappels d\'événements</string>
<string name="settings_reminders_hint">Vous voyez des rappels deux fois? Une autre application de calendrier les publie aussi — désactivez-les dans lun des deux.</string>
<string name="settings_reminders_hint">Vous voyez des rappels deux fois ? Une autre application de calendrier les publie aussi — désactivez-les dans lun des deux.</string>
<string name="settings_default_reminder">rappel par défaut</string>
<string name="settings_default_reminder_allday">événements d\'une journée entière</string>
<string name="settings_allday_reminder_time">Heure de rappel toute la journée</string>
@@ -383,9 +383,9 @@
<string name="settings_calendar_reminders_managed_hint">Définir dans les dates spéciales de contact</string>
<string name="settings_special_dates_paused_title">suspendu</string>
<string name="settings_special_dates_paused_hint">Calendula ne peut plus lire vos contacts, donc ces calendriers ne se mettent pas à jour.</string>
<string name="settings_special_dates_disable_title">Désactiver les dates de contact?</string>
<string name="settings_special_dates_disable_title">Désactiver les dates de contact ?</string>
<string name="settings_special_dates_disable_all_message">Cela supprime les calendriers de contact et leurs événements. Tous les rappels ou notes que vous leur avez ajoutés seront perdus.</string>
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
<string name="settings_special_dates_disable_confirm">désactiver</string>
<string name="settings_section_about">A propos</string>
<string name="settings_license">Licence</string>
@@ -411,7 +411,7 @@
<string name="calendars_edit_title">Editer un agenda</string>
<string name="calendars_name_label">Nom</string>
<string name="calendars_description_hint">Ajouter une description</string>
<string name="calendars_delete_confirm_title">Supprimer un calendrier?</string>
<string name="calendars_delete_confirm_title">Supprimer un calendrier ?</string>
<string name="calendars_delete_confirm_message">\"%1$s\" et tous ses événements seront définitivement retirés de cet appareil.</string>
<string name="calendars_write_error">Impossible de sauvegarder les changements.</string>
<string name="calendars_backup_hint">Les calendriers locaux ne sont synchronisés nulle part, alors exportez-les vers un fichier .ics pour en garder une copie.</string>
@@ -429,8 +429,8 @@
<string name="calendars_auto_backup_interval">Intervalle</string>
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
<string name="calendars_auto_backup_status_never">Pas encore de sauvegarde automatique</string>
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
<string name="backup_channel_description">Avertit si les sauvegardes automatiques échouent de manière répétée.</string>
<string name="backup_failed_title">La sauvegarde automatique a échoué</string>
<string name="backup_failed_text">Calendula na pas pu écrire le fichier de sauvegarde. Vérifiez le dossier de sauvegarde dans les paramètres.</string>
@@ -481,7 +481,7 @@
</plurals>
<string name="shortcut_new_event_long">créer un événement</string>
<string name="settings_qs_tile">Ajouter des paramètres rapides</string>
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
<string name="crash_dialog_title">%1$s a planté</string>
<string name="crash_dialog_message">%1$s a été fermé de manière inattendue la dernière fois. Vous pouvez aider à le corriger en envoyant ce rapport en tant que problème. Il reste sur votre appareil jusquà ce que vous choisissiez de le partager, et ninclut aucune donnée personnelle ni contenu de calendrier — seuls les détails techniques ci-dessous.</string>
<string name="crash_dialog_report">rapport</string>

View File

@@ -242,6 +242,10 @@
<item quantity="one">%d day</item>
<item quantity="other">%d days</item>
</plurals>
<!-- A mixed duration: %1$s is the hours part, %2$s the minutes part. -->
<string name="duration_hours_minutes">%1$s %2$s</string>
<!-- Shown instead of the preview when a custom length exceeds what the setting keeps. %1$s is the longest allowed, e.g. "24 hours". -->
<string name="duration_custom_max">At most %1$s</string>
<plurals name="duration_weeks">
<item quantity="one">%d week</item>
<item quantity="other">%d weeks</item>
@@ -440,6 +444,14 @@
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
<string name="settings_autofocus_title">Focus title on new event</string>
<string name="settings_autofocus_title_hint">When you start a new event, place the cursor in the title field and open the keyboard right away.</string>
<string name="settings_event_duration">Default duration</string>
<string name="settings_event_duration_hint">How long a new event lasts until you change its end time. All-day events aren\'t affected.</string>
<string name="settings_calendar_durations_title">Per-calendar duration</string>
<string name="settings_calendar_durations_hint">Give a calendar its own default length — e.g. 8 hours for work shifts.</string>
<!-- Row summary for a calendar with no duration of its own. %1$s is the default length, e.g. "1 hour". -->
<string name="settings_calendar_duration_inherits">Default (%1$s)</string>
<!-- Picker row that drops a calendar\'s own length. %1$s is the default length. -->
<string name="settings_calendar_duration_use_default">Use default duration (%1$s)</string>
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
<string name="settings_section_notifications">Notifications</string>

View File

@@ -502,6 +502,56 @@ class SettingsPrefsTest {
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
}
@Test
fun `event duration defaults to an hour and clamps to a sane span`(
@TempDir tempDir: Path,
) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultEventDurationMinutes.first()).isEqualTo(60)
prefs.setDefaultEventDurationMinutes(480)
assertThat(prefs.defaultEventDurationMinutes.first()).isEqualTo(480)
prefs.setDefaultEventDurationMinutes(0)
assertThat(prefs.defaultEventDurationMinutes.first()).isEqualTo(1)
prefs.setDefaultEventDurationMinutes(5_000)
assertThat(prefs.defaultEventDurationMinutes.first()).isEqualTo(1_440)
}
@Test
fun `per-calendar duration round-trips and clears back to inherit`(
@TempDir tempDir: Path,
) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.perCalendarEventDuration.first()).isEmpty()
prefs.setCalendarEventDuration(7L, 480)
prefs.setCalendarEventDuration(9L, 30)
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(7L, 480, 9L, 30)
prefs.setCalendarEventDuration(7L, null)
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 30)
}
@Test
fun `garbage per-calendar duration entries are dropped, the rest survive`(
@TempDir tempDir: Path,
) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
// A bad id, a non-numeric length, one out of range, and a good entry.
m[SettingsPrefs.CALENDAR_EVENT_DURATION_KEY] = "x=60;7=soon;8=99999;9=45"
m
}
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 45)
}
@Test
fun `resolveDefaultEventDuration prefers the calendar's own length`() {
val overrides = mapOf(7L to 480)
assertThat(resolveDefaultEventDuration(60, overrides, calendarId = 7L)).isEqualTo(480)
assertThat(resolveDefaultEventDuration(60, overrides, calendarId = 9L)).isEqualTo(60)
assertThat(resolveDefaultEventDuration(60, overrides, calendarId = null)).isEqualTo(60)
}
@Test
fun `custom-font stamps default to zero and bump per role independently`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))

View File

@@ -126,4 +126,24 @@ class TimelineScaleTest {
assertThat(parseTimelineScale(stored)).isEqualTo(TimelineScale.Regular)
}
}
@Test
fun `a tap creates at the hour it landed in, at whatever scale`() {
// Same point on the same column reads as a different hour once the
// timeline has been pinched — which is the whole of #148: the tap has to
// be measured against the height the column is drawing at now.
assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 100f)).isEqualTo(5 * 60)
assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 50f)).isEqualTo(10 * 60)
// Within an hour it snaps back to that hour's start.
assertThat(tappedMinuteOfDay(offsetY = 599f, hourPx = 100f)).isEqualTo(5 * 60)
}
@Test
fun `a tap outside the day stays inside it`() {
assertThat(tappedMinuteOfDay(offsetY = -20f, hourPx = 56f)).isEqualTo(0)
assertThat(tappedMinuteOfDay(offsetY = 99_999f, hourPx = 56f)).isEqualTo(23 * 60)
// A viewport measured at nothing has no grid to read — midnight, not a
// division by zero.
assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 0f)).isEqualTo(0)
}
}

View File

@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -22,6 +23,9 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.Dispatchers
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -85,9 +89,11 @@ class EventEditViewModelTest {
private fun viewModel(
tempDir: Path,
fake: FakeCalendarDataSource,
// Passed in by the tests that need to seed a setting first; a second
// DataStore on the same file would clash, so it is built only once.
s: SettingsPrefs = settings(tempDir),
): EventEditViewModel {
val p = prefs(tempDir)
val s = settings(tempDir)
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
return EventEditViewModel(repo, p, s, dispatcher)
}
@@ -200,6 +206,120 @@ class EventEditViewModelTest {
job.cancel()
}
@Test
fun `a new event takes its calendar's default duration, and follows a switch`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
val s = settings(tempDir)
s.setDefaultEventDurationMinutes(30)
s.setCalendarEventDuration(2L, 480)
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 9 * 60)
advanceUntilIdle()
// Calendar 1 has no length of its own, so it inherits the global 30 min.
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
vm.setCalendar(2L)
advanceUntilIdle()
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 17, 0))
job.cancel()
}
@Test
fun `an end time set by hand survives a calendar switch`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
val s = settings(tempDir)
s.setCalendarEventDuration(2L, 480)
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 9 * 60)
advanceUntilIdle()
vm.setEndTime(LocalTime(10, 15))
vm.setCalendar(2L)
advanceUntilIdle()
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 15))
job.cancel()
}
@Test
fun `an imported event keeps the span its source named`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
val s = settings(tempDir)
s.setDefaultEventDurationMinutes(30)
s.setCalendarEventDuration(2L, 480)
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openImported(importedForm(end = LocalDateTime(2030, 1, 15, 11, 30)), ImportSource.File)
advanceUntilIdle()
vm.setCalendar(2L)
advanceUntilIdle()
// The file owns the length; a calendar switch must not restretch it.
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 11, 30))
job.cancel()
}
@Test
fun `an insert intent with no end takes the default duration`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
val s = settings(tempDir)
s.setDefaultEventDurationMinutes(30)
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
// The placeholder hour buildInsertEventForm fills in for a missing end.
vm.openImported(
importedForm(end = LocalDateTime(2030, 1, 15, 10, 0)),
ImportSource.InsertOpenEnded,
)
advanceUntilIdle()
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
job.cancel()
}
private fun importedForm(end: LocalDateTime) = EventForm(
calendarId = null,
title = "Imported",
start = LocalDateTime(2030, 1, 15, 9, 0),
end = end,
)
@Test
fun `an all-day event ignores the default duration`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
val s = settings(tempDir)
s.setCalendarEventDuration(2L, 480)
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 9 * 60)
advanceUntilIdle()
vm.setAllDay(true)
vm.setCalendar(2L)
advanceUntilIdle()
// Dates carry no length: the times stay where the form put them.
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 0))
job.cancel()
}
@Test
fun `editing a recurring event without moving still asks for the scope`(
@TempDir tempDir: Path,

View File

@@ -0,0 +1,12 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.4 5.4H18.6M18.6 9.43V4.7C18.6 2.99 17.21 1.6 15.5 1.6H4.5C2.79 1.6 1.4 2.99 1.4 4.7V15.7C1.4 17.41 2.79 18.8 4.5 18.8H9.63" stroke="black" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.6 16.35Q20.42 18.65 23.25 16.35Q20.42 14.05 17.6 16.35Z" fill="black"/>
<path d="M17.26 17.16Q17.63 20.79 21.26 21.16Q20.89 17.53 17.26 17.16Z" fill="black"/>
<path d="M16.45 17.5Q14.15 20.33 16.45 23.15Q18.75 20.33 16.45 17.5Z" fill="black"/>
<path d="M15.64 17.16Q12.01 17.53 11.64 21.16Q15.27 20.79 15.64 17.16Z" fill="black"/>
<path d="M15.3 16.35Q12.47 14.05 9.65 16.35Q12.47 18.65 15.3 16.35Z" fill="black"/>
<path d="M15.64 15.54Q15.27 11.91 11.64 11.54Q12.01 15.17 15.64 15.54Z" fill="black"/>
<path d="M16.45 15.2Q18.75 12.38 16.45 9.55Q14.15 12.38 16.45 15.2Z" fill="black"/>
<path d="M17.26 15.54Q20.89 15.17 21.26 11.54Q17.63 11.91 17.26 15.54Z" fill="black"/>
<circle cx="16.45" cy="16.35" r="2.5" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -33,15 +33,27 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`
the F-Droid per-version changelog.
3. Bump the committed `versionName` (and `versionCode`) in
`app/build.gradle.kts` to the new version. **This bump is what triggers the
release** when the branch merges to `main`. Then run
release** when the branch merges to `main`. Then write the per-version
"What's New" by hand to
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` and commit it.
**Keep it under 500 characters.** That one file is what both the **official**
F-Droid repo (which reads it from the tagged source tree) and **Google Play**
publish, and Play caps "What's New" at 500 characters while F-Droid truncates
long entries in-client. So it is a short summary — a handful of bullets
naming the headline changes — not a copy of the CHANGELOG.md section, which
runs to thousands of characters. `CHANGELOG.md` stays the full account and is
what the Gitea/Codeberg release notes use.
Then run
```bash
scripts/sync_changelog_to_fastlane.sh
```
and commit the generated
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`. This is what
makes the **official** F-Droid repo show this version's changelog (it reads
the changelog from the tagged source tree). The self-hosted pipeline
regenerates it regardless, so forgetting only affects the official listing.
to check it. The script **keeps** a committed file untouched and only warns
if it is over the limit; it extracts the CHANGELOG.md section as a fallback
solely when the file is missing, so the self-hosted pipeline always has
something to publish. The pipeline runs the same script, so forgetting to
write the file yields a long auto-generated changelog rather than none.
4. **Verify the release build on a real device** — the mandatory gate. The
shipped APK is R8-shrunk/obfuscated, and bugs that only appear there, or
only on first run, never show up in the debug build or on a device that
@@ -142,8 +154,9 @@ because the store listing already lives in `fastlane/metadata/android/` — the
same tree the official F-Droid repo harvests. One metadata source, two stores.
**What gets uploaded per release:** the AAB, plus the per-version "What's New"
from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` (generated
from `CHANGELOG.md` by `scripts/sync_changelog_to_fastlane.sh`). Listing text is
from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` — the
hand-written summary from step 3, which is why it must stay **under 500
characters**: Play rejects a longer one. Listing text is
**not** touched — an accidental overwrite of a live listing triggers a Play
policy review. Sync it deliberately with `bundle exec fastlane listing`.

View File

@@ -0,0 +1,2 @@
إصلاحات
• عرض الأسبوع واليوم: النقر على مساحة فارغة يُنشئ الحدث في الساعة التي نقرت عليها، حتى بعد تكبير شريط الوقت أو تغيير ارتفاع الساعة.

View File

@@ -0,0 +1,2 @@
Behoben
• Wochen- und Tagesansicht: Ein Tippen auf einen freien Bereich legt den Termin jetzt zur angetippten Uhrzeit an auch nachdem die Zeitleiste gezoomt oder die Stundenhöhe geändert wurde.

View File

@@ -0,0 +1,2 @@
Fixed
• Week and day view: tapping an empty slot now creates the event at the hour you tapped, even after zooming the timeline or changing the hour height.

View File

@@ -0,0 +1,12 @@
New
• Default event duration — how long a new event opens, per calendar.
• Week & day: choose an hour height, or pinch to zoom. "Fit whole day" shows all 24 hours at once.
• Tap the date in the top bar to jump to another day.
Changed
• Calendar colours reworked so event text is always readable.
• Reminders now carry Calendula's own status-bar icon.
Fixed
• Back closes the sidebar instead of the app, and its rows line up.
• Status-bar icons follow the app's light/dark choice.

View File

@@ -0,0 +1,2 @@
Fixed
• Week and day view: tapping an empty slot now creates the event at the hour you tapped, even after zooming the timeline or changing the hour height.

View File

@@ -0,0 +1,2 @@
Corregido
• Vista de semana y día: al tocar un hueco libre, el evento se crea a la hora que has tocado, incluso después de ampliar la línea de tiempo o cambiar la altura de la hora.

View File

@@ -0,0 +1,2 @@
Corrigé
• Vues Semaine et Jour : appuyer sur un créneau vide crée désormais l'événement à l'heure touchée, même après avoir zoomé sur la grille ou modifié la hauteur d'une heure.

View File

@@ -0,0 +1,2 @@
Corretto
• Vista settimana e giorno: toccando uno spazio libero l'evento viene creato all'ora toccata, anche dopo aver ingrandito la linea temporale o cambiato l'altezza dell'ora.

View File

@@ -0,0 +1,2 @@
Poprawki
• Widok tygodnia i dnia: dotknięcie pustego miejsca tworzy teraz wydarzenie o dotkniętej godzinie, także po powiększeniu osi czasu lub zmianie wysokości godziny.

View File

@@ -0,0 +1,2 @@
Corrigido
• Vista de semana e de dia: tocar num espaço livre cria o evento à hora tocada, mesmo depois de ampliar a linha temporal ou alterar a altura da hora.

View File

@@ -0,0 +1,2 @@
Исправлено
• Вид недели и дня: нажатие на свободное место создаёт событие на выбранный час — в том числе после масштабирования шкалы времени или изменения высоты часа.

View File

@@ -0,0 +1,2 @@
修复
• 周视图和日视图:点按空白处会在所点的时间创建事件,即使在缩放时间轴或更改小时高度之后也是如此。

View File

@@ -22,7 +22,7 @@ kotlinxDatetime = "0.7.0"
kotlinxCoroutines = "1.10.2"
turbine = "1.2.1"
hiltNavigationCompose = "1.3.0"
lifecycleCompose = "2.10.0"
lifecycleCompose = "2.11.0"
androidxTestRules = "1.7.0"
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
glance = "1.1.1"

View File

@@ -1,19 +1,24 @@
#!/usr/bin/env bash
# Write the current version's CHANGELOG.md section into the fastlane changelog
# file that F-Droid harvests: fastlane/metadata/android/en-US/changelogs/<code>.txt
# (en-US is F-Droid's fallback locale, so it covers every language).
# Ensure the current version's "What's New" exists at
# fastlane/metadata/android/en-US/changelogs/<code>.txt — the one file both the
# official F-Droid repo and Google Play read (en-US is F-Droid's fallback
# locale, so it covers every language).
#
# Run this when cutting a release (after editing CHANGELOG.md and bumping
# versionName in app/build.gradle.kts) and COMMIT the result, so the OFFICIAL
# F-Droid repo — which reads the changelog from the tagged source treeshows
# this version's "What's New". The self-hosted release pipeline also runs it so
# its changelog never depends on the file having been committed. Idempotent.
# A COMMITTED file wins and is never rewritten. Play caps "What's New" at 500
# characters and F-Droid truncates long entries in-client, so this file is a
# hand-written summary, not a copy of the CHANGELOG.md sectionthose run to
# thousands of characters. Write it when cutting a release, keep it under 500,
# and commit it.
#
# Extraction matches the awk used for the Gitea release notes so all three
# (release notes, self-hosted changelog, official changelog) stay in sync.
# Only when the file is missing does this fall back to extracting the
# CHANGELOG.md section, so the self-hosted release pipeline always has
# something to publish. The extraction matches the awk used for the Gitea
# release notes.
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
LIMIT=500
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; }
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
@@ -24,18 +29,22 @@ CL_DIR="fastlane/metadata/android/en-US/changelogs"
mkdir -p "$CL_DIR"
OUT="$CL_DIR/${VERSION_CODE}.txt"
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > "$OUT"
# Trim leading blank lines (same as the pipeline did).
sed -i -e '/./,$!d' "$OUT"
if [ ! -s "$OUT" ]; then
echo "See CHANGELOG.md for $VERSION." > "$OUT"
if [ -s "$OUT" ]; then
ACTION="Kept"
else
ACTION="Generated"
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > "$OUT"
# Trim leading blank lines (same as the pipeline did).
sed -i -e '/./,$!d' "$OUT"
[ -s "$OUT" ] || echo "See CHANGELOG.md for $VERSION." > "$OUT"
fi
CHARS=$(wc -m < "$OUT" | tr -d ' ')
echo "Wrote $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
if [ "$CHARS" -gt 500 ]; then
echo " note: >500 chars — F-Droid may truncate this changelog in-client." >&2
echo "$ACTION $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
if [ "$CHARS" -gt "$LIMIT" ]; then
echo " warning: >${LIMIT} chars — Play rejects this and F-Droid truncates it." >&2
echo " Replace $OUT with a hand-written summary under ${LIMIT} chars." >&2
fi