diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index b821041..46fe5fc 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf0f66..19e5eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 +32,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 +59,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 +1314,12 @@ 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index beebbc0..595c6ce 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 = 21800 + versionName = "2.18.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index f1e52ec..e8bdb5c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -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(null) + private var requestedInsert by mutableStateOf(null) // An external "edit this event" (ACTION_EDIT on content://.../events/): // 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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 2713f3c..b5d0ecf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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 = 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> = 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, + 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 = when { private fun List.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 = + 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.toStoredDurations(): String = + entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) -> + "$id$KEY_VALUE_SEP$minutes" + } + private inline fun > String?.toEnum(default: E): E = this?.let { stored -> enumValues().firstOrNull { it.name == stored } } ?: default diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt index 059f301..2dd0db8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt @@ -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. * diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 41f3f21..02f10a3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -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() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt index 49e90d0..14d4525 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt new file mode 100644 index 0000000..4857aa0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt @@ -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 }, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index 44ab0b1..6c25a2c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -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, - 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 1–999 - * amount. [onConfirm] receives the final delay in minutes. + * the length it resolves to, and a tonal confirm enabled only for a valid 1–999 + * 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) }, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt index 74da165..1ee9778 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt @@ -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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index b616075..1027d13 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -68,6 +68,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 @@ -225,6 +226,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 +367,7 @@ private fun DayTopBar( onCycleView: () -> Unit, onOpenDrawer: () -> Unit, onOpenSearch: () -> Unit, + onJumpToDate: (LocalDate) -> Unit, showTodayButton: Boolean, onToday: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, @@ -372,9 +375,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 = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 93e3c1d..2c3dc61 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -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(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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 1561e16..3152cf1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -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 = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt index 585fd28..61ffe0a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt @@ -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(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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt index 3735b30..0b62f48 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 680fa5a..c3cf3fc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -66,6 +66,10 @@ data class SettingsUiState( val defaultFormFields: Set = 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 = emptyMap(), /** Whether Calendula posts reminder notifications (v1.4). */ val remindersEnabled: Boolean = true, /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 27397fb..8b49d6e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -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>, val calendars: List, val managedIds: Set, + val durations: EventDurations, + ) + + private data class EventDurations( + val default: Int, + val perCalendar: Map, ) 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) { viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 6b0e517..f4ff867 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -77,6 +77,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 @@ -246,6 +247,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 +400,7 @@ private fun WeekTopBar( onCycleView: () -> Unit, onOpenDrawer: () -> Unit, onOpenSearch: () -> Unit, + onJumpToDate: (LocalDate) -> Unit, showTodayButton: Boolean, onToday: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, @@ -405,9 +408,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 = { diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml index 34e1c4d..069d6fd 100644 --- a/app/src/main/res/drawable/ic_notification.xml +++ b/app/src/main/res/drawable/ic_notification.xml @@ -1,12 +1,54 @@ - + + android:viewportHeight="24"> + + 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" /> + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 621ca08..1e296d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -242,6 +242,10 @@ %d day %d days + + %1$s %2$s + + At most %1$s %d week %d weeks @@ -440,6 +444,14 @@ Fields shown by default — everything else sits behind \"More fields\" Focus title on new event When you start a new event, place the cursor in the title field and open the keyboard right away. + Default duration + How long a new event lasts until you change its end time. All-day events aren\'t affected. + Per-calendar duration + Give a calendar its own default length — e.g. 8 hours for work shifts. + + Default (%1$s) + + Use default duration (%1$s) Allow colors on unsupported calendars 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. Notifications diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index c8754c9..76eafa9 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -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)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt index e087fbe..cb3b8de 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt @@ -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, diff --git a/design/icon/calendula_notification.svg b/design/icon/calendula_notification.svg new file mode 100644 index 0000000..bae390a --- /dev/null +++ b/design/icon/calendula_notification.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 758760a..19537f7 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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/.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/.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/.txt` (generated -from `CHANGELOG.md` by `scripts/sync_changelog_to_fastlane.sh`). Listing text is +from `fastlane/metadata/android/en-US/changelogs/.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`. diff --git a/fastlane/metadata/android/en-US/changelogs/21800.txt b/fastlane/metadata/android/en-US/changelogs/21800.txt new file mode 100644 index 0000000..97acb85 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21800.txt @@ -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. diff --git a/scripts/sync_changelog_to_fastlane.sh b/scripts/sync_changelog_to_fastlane.sh index f6d0e93..f446965 100755 --- a/scripts/sync_changelog_to_fastlane.sh +++ b/scripts/sync_changelog_to_fastlane.sh @@ -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/.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/.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 tree — shows -# 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 section — those 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