Compare commits
6 Commits
7c1a03eac1
...
v2.18.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8a83a39d0 | ||
|
|
9557ca73ed | ||
|
|
9767fbbbaf | ||
|
|
9054742503 | ||
|
|
3d62036d79 | ||
|
|
34a8e91ea5 |
@@ -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"
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package de.jeanlucmakiola.calendula
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -12,6 +14,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -36,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
|
||||
@@ -52,9 +56,17 @@ 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() {
|
||||
|
||||
// Which of light/dark the system bars are drawn for. The styles installed
|
||||
// in onCreate read this field live, so androidx's config-change replay
|
||||
// picks up the in-app override instead of the night resource qualifier.
|
||||
private var systemBarsDark = false
|
||||
|
||||
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||
// tap into the running activity; CalendarHost consumes and clears it.
|
||||
@@ -71,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
|
||||
@@ -98,11 +110,13 @@ class MainActivity : AppCompatActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||
Configuration.UI_MODE_NIGHT_YES
|
||||
applyEdgeToEdge()
|
||||
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 {
|
||||
@@ -115,6 +129,14 @@ class MainActivity : AppCompatActivity() {
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
// onCreate can only see the night resource qualifier, not the in-app
|
||||
// override — re-apply from the resolved theme so the bar icons follow
|
||||
// the app's light/dark choice.
|
||||
DisposableEffect(darkTheme) {
|
||||
systemBarsDark = darkTheme
|
||||
applyEdgeToEdge()
|
||||
onDispose {}
|
||||
}
|
||||
// The app-wide clock convention: the time-format preference resolved
|
||||
// against the device's 24-hour system setting, provided once here so
|
||||
// every time label reads it via LocalUse24HourFormat.
|
||||
@@ -161,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 },
|
||||
)
|
||||
@@ -191,6 +214,19 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the transparent edge-to-edge bars for the current [systemBarsDark].
|
||||
* Both scrims are transparent because API 29+ enforces its own contrast and
|
||||
* ignores them anyway.
|
||||
*/
|
||||
private fun applyEdgeToEdge() {
|
||||
val transparent = android.graphics.Color.TRANSPARENT
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
|
||||
navigationBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||
@@ -202,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 }
|
||||
}
|
||||
|
||||
@@ -229,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.
|
||||
@@ -239,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),
|
||||
@@ -250,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,
|
||||
|
||||
@@ -112,6 +112,7 @@ fun AgendaScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = anchor,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
@@ -27,19 +29,23 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
@@ -50,7 +56,8 @@ import kotlinx.datetime.LocalDate
|
||||
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
|
||||
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
|
||||
* tapping a view here selects it (and closes the drawer) rather than cycling.
|
||||
* The host screen owns the drawer state.
|
||||
* The host screen owns the drawer state; the sheet reads it only to dismiss
|
||||
* itself on back.
|
||||
*
|
||||
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
||||
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
||||
@@ -59,12 +66,17 @@ import kotlinx.datetime.LocalDate
|
||||
fun CalendarDrawer(
|
||||
currentView: CalendarView,
|
||||
currentDate: LocalDate,
|
||||
drawerState: DrawerState,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
) {
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Registered in the sheet so it takes precedence over the host's back handler.
|
||||
BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } }
|
||||
|
||||
ModalDrawerSheet {
|
||||
// The whole sidebar scrolls as one — header, views, the calendar filter
|
||||
@@ -83,7 +95,7 @@ fun CalendarDrawer(
|
||||
position = positionOf(index, viewOrder.size),
|
||||
selected = view == currentView,
|
||||
minHeight = 56.dp,
|
||||
leading = { Icon(view.icon, contentDescription = null) },
|
||||
leading = { DrawerLeadingIcon(view.icon) },
|
||||
onClick = { onSelectView(view) },
|
||||
)
|
||||
}
|
||||
@@ -93,7 +105,7 @@ fun CalendarDrawer(
|
||||
title = stringResource(R.string.drawer_jump_to_date),
|
||||
position = Position.Alone,
|
||||
minHeight = 56.dp,
|
||||
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) },
|
||||
leading = { DrawerLeadingIcon(Icons.Filled.DateRange) },
|
||||
onClick = { showDatePicker = true },
|
||||
)
|
||||
|
||||
@@ -107,7 +119,7 @@ fun CalendarDrawer(
|
||||
title = stringResource(R.string.month_action_settings),
|
||||
position = Position.Alone,
|
||||
minHeight = 56.dp,
|
||||
leading = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||
leading = { DrawerLeadingIcon(Icons.Filled.Settings) },
|
||||
onClick = onSettings,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -126,13 +138,27 @@ fun CalendarDrawer(
|
||||
}
|
||||
}
|
||||
|
||||
/** Leading slot for the drawer's plain icons: the same 40.dp footprint
|
||||
* [CalendarColorChip] takes, so every leading glyph shares one vertical axis. */
|
||||
@Composable
|
||||
private fun DrawerLeadingIcon(icon: ImageVector) {
|
||||
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(icon, contentDescription = null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Branded header: the app-icon chip beside the app name. */
|
||||
@Composable
|
||||
private fun DrawerHeader() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp),
|
||||
.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 24.dp,
|
||||
bottom = 16.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
@@ -164,6 +190,11 @@ private fun DrawerSectionHeader(text: String) {
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -198,6 +199,7 @@ fun DayScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = date,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
@@ -224,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,
|
||||
@@ -364,6 +367,7 @@ private fun DayTopBar(
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
@@ -371,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
|
||||
|
||||
@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
@@ -73,7 +74,12 @@ private fun FilterList(
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 12.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
group.calendars.forEachIndexed { index, cal ->
|
||||
GroupedRow(
|
||||
@@ -103,7 +109,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
|
||||
repeat(4) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 28.dp)
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(
|
||||
@@ -129,6 +135,6 @@ private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier)
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 28.dp, vertical = 24.dp),
|
||||
.padding(horizontal = GroupedListInset, vertical = 24.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -330,6 +331,7 @@ fun MonthScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
@@ -351,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,
|
||||
@@ -510,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
|
||||
@@ -219,6 +220,7 @@ fun WeekScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = weekStart,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
@@ -245,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,
|
||||
@@ -397,6 +400,7 @@ private fun WeekTopBar(
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
@@ -404,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>
|
||||
|
||||
@@ -297,4 +297,16 @@
|
||||
<string name="settings_agenda_range">مدى الجدول</string>
|
||||
<string name="agenda_range_custom">مخصص…</string>
|
||||
<string name="agenda_range_custom_hint">أيام</string>
|
||||
<string name="agenda_range_week">٧ أيام القادمة</string>
|
||||
<string name="agenda_range_month">٣٠ يومًا القادمة</string>
|
||||
<string name="settings_month_view_style">نمط عرض الشهر</string>
|
||||
<string name="month_style_paged">صفحات</string>
|
||||
<string name="month_style_paged_summary">الشهر الواحد يملأ الشاشة. اسحب لليسار أو لليمين لتغيير الشهر.</string>
|
||||
<string name="month_style_split">تقسيم</string>
|
||||
<string name="settings_color_unsupported">السماح بالألوان في التقويمات غير المدعومة</string>
|
||||
<string name="settings_color_unsupported_hint">بعض التقويمات (مثل البعض من CalDAV) لا تنشر أي مجموعة ألوان؛ لون حدث مخصص قد يزال أو يستبدل في مزامنته التالية. هذا قيد على تلك التقويمات، وليس شيئًا يمكن لـ Calendula إصلاحه.</string>
|
||||
<string name="settings_section_views">طُرق العرض</string>
|
||||
<string name="agenda_range_showing_label">إظهار جميع الأحداث القادمة لـ</string>
|
||||
<string name="settings_reminders">تذكيرات الحدث</string>
|
||||
<string name="settings_default_reminder">التذكير الافتراضي</string>
|
||||
</resources>
|
||||
|
||||
@@ -490,4 +490,12 @@
|
||||
</plurals>
|
||||
<string name="event_detail_duplicate">Duplikate</string>
|
||||
<string name="reminder_day_tomorrow">Morgen</string>
|
||||
<string name="event_edit_timezone_device">Zeitzone des Gerätes</string>
|
||||
<string name="event_edit_timezone_device_summary">Passt sich an wo Sie sind</string>
|
||||
<string name="event_edit_timezone_search">Zeitzonen durchsuchen</string>
|
||||
<string name="event_edit_timezone_recent">Kürzliche</string>
|
||||
<string name="event_edit_timezone_all">Alle Zeitzonen</string>
|
||||
<string name="event_edit_timezone_none">Keine Zeitzone passt zu “%1$s”</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s deiner Zeit</string>
|
||||
<string name="import_reminder_prompt_title">Deine Standarterinnerung anwenden?</string>
|
||||
</resources>
|
||||
|
||||
@@ -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" '
|
||||
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"
|
||||
if [ ! -s "$OUT" ]; then
|
||||
echo "See CHANGELOG.md for $VERSION." > "$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