2.18.0 (#126)
All checks were successful
All checks were successful
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/126
This commit is contained in:
@@ -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
|
||||
|
||||
36
CHANGELOG.md
36
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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 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) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
/**
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
12
design/icon/calendula_notification.svg
Normal file
12
design/icon/calendula_notification.svg
Normal 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 |
@@ -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`.
|
||||
|
||||
|
||||
12
fastlane/metadata/android/en-US/changelogs/21800.txt
Normal file
12
fastlane/metadata/android/en-US/changelogs/21800.txt
Normal 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.
|
||||
@@ -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 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
|
||||
|
||||
Reference in New Issue
Block a user