Compare commits
26 Commits
v2.11.0
...
40d70d9f2b
| Author | SHA1 | Date | |
|---|---|---|---|
| 40d70d9f2b | |||
| 14b19cd902 | |||
| 8d530c5681 | |||
| 225f4c3491 | |||
| 11578a5588 | |||
| 5d887524b1 | |||
| 405ff16233 | |||
| 048f407ba1 | |||
| a89560953d | |||
| 4e498de051 | |||
| f2fb3d6279 | |||
| c2d88e744e | |||
| 2ae4c818ba | |||
| fb2a40114a | |||
| e1f79df67a | |||
| e258a4a7f2 | |||
| 3f27a47d1e | |||
| feb87a1e73 | |||
| 28e464a616 | |||
| 93df6d6d62 | |||
| 49ddb9437b | |||
| e1cf00999d | |||
| 2d0d707a7d | |||
| de1fe31223 | |||
| 273cfce969 | |||
| b6a45b7264 |
82
CHANGELOG.md
82
CHANGELOG.md
@@ -5,6 +5,79 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Set more than one default reminder per calendar. A calendar's default
|
||||
reminders — and the global defaults under Settings → Notifications — can now
|
||||
hold several lead times instead of just one, so new events can start with, say,
|
||||
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||
multi-select; per-calendar overrides can still inherit the global default or
|
||||
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||
- Widget headers now open the app. Tapping the month/year title on the month
|
||||
widget opens the app on the month view, and tapping the "Upcoming" title on
|
||||
the agenda widget opens it on your default view — so there's a one-tap way
|
||||
back into the app that lands where you'd expect, instead of only through a day
|
||||
or event. The month widget's "today" button now snaps the grid back to the
|
||||
current month in place. Thanks to @rgz46vic and @ptab for the suggestions
|
||||
([#18], [#20]).
|
||||
|
||||
### Fixed
|
||||
- Month widget arrows and "today" button work again. On release builds the
|
||||
prev/next-month arrows and the jump-to-today control on the month widget did
|
||||
nothing when tapped — code shrinking had stripped the tap handlers behind
|
||||
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
|
||||
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
||||
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||
popping up — matching how a disabled calendar's events already stay hidden
|
||||
everywhere else in the app ([#17]).
|
||||
- Editing a single occurrence of a recurring event works again. Choosing **Only
|
||||
this event** and saving a change to one event in a repeating series silently
|
||||
did nothing — the change was rejected and the edit form simply reappeared with
|
||||
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||
lands on just that one event and leaves the rest of the series untouched
|
||||
([#16]).
|
||||
|
||||
## [2.12.0] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- See past events your way. Two new settings change how events that have already
|
||||
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
|
||||
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
|
||||
usual, dim them, or hide them from the list entirely. **Dim completed events**
|
||||
(Settings → Appearance) separately fades finished events in the month and week
|
||||
views. Both are off by default, an event only counts as finished once it has
|
||||
actually ended (events still in progress are never dimmed), and the lists
|
||||
update on their own as the day goes on. Thanks to @ptab for the suggestion
|
||||
([#12]).
|
||||
|
||||
### Changed
|
||||
- Readable titles on overlapping events. In the week and day views, events that
|
||||
overlap split a day into slim columns where the title used to be clipped to a
|
||||
character or two. The title now wraps across as many lines as the block can
|
||||
fit, and the time is hidden on those narrow blocks so the name can fill the
|
||||
space — so you can tell events apart without opening each one. Thanks to @ptab
|
||||
for the suggestion ([#13]).
|
||||
|
||||
## [2.11.2] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- Jump straight to typing a title. When you start a new event, Calendula now puts
|
||||
the cursor in the title field and opens the keyboard right away, so you can type
|
||||
the name without an extra tap. It's on by default and only affects creating an
|
||||
event — editing never grabs focus — and a new **Focus title on new event**
|
||||
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
|
||||
suggestion ([#10]).
|
||||
|
||||
## [2.11.1] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- Calendula can now be set as your default calendar app. It registers the
|
||||
calendar-app intent filters the system uses, so it appears in the chooser when
|
||||
you tap a date in a launcher or clock — and opening one takes you straight to
|
||||
that day. Android has no way for an app to make itself the default, so you pick
|
||||
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
|
||||
|
||||
## [2.11.0] — 2026-06-27
|
||||
|
||||
### Added
|
||||
@@ -685,3 +758,12 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
|
||||
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
|
||||
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
||||
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
|
||||
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
|
||||
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
||||
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
||||
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||
|
||||
@@ -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 = 21100
|
||||
versionName = "2.11.0"
|
||||
versionCode = 21200
|
||||
versionName = "2.12.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -90,6 +90,8 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
// BuildConfig.DEBUG gates the in-app debug ribbon (see DebugRibbon).
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
||||
|
||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -16,6 +16,18 @@
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
-dontwarn androidx.room.paging.**
|
||||
|
||||
# Glance runs an @Composable's `actionRunCallback<T>()` by persisting the
|
||||
# callback's fully-qualified class name into the click PendingIntent, then
|
||||
# reflectively instantiating it (Class.forName(name).newInstance()) when the tap
|
||||
# fires. Under R8 full mode (AGP 9 default) these ActionCallback classes — only
|
||||
# ever referenced reflectively — get renamed or have their no-arg constructor
|
||||
# stripped, so the lookup fails silently and the tap does nothing. In the month
|
||||
# and agenda widgets that broke every run-callback control (the prev/next/today
|
||||
# month arrows and the agenda refresh) in release builds while actionStartActivity
|
||||
# taps, which ride a PendingIntent and need no reflection, kept working. Keep
|
||||
# every ActionCallback's name and constructor intact.
|
||||
-keep class * implements androidx.glance.appwidget.action.ActionCallback { <init>(...); }
|
||||
|
||||
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
|
||||
# the fully-qualified class name persisted in the WorkSpec, so the class must
|
||||
# keep both its name and a no-arg constructor. Glance renders every widget
|
||||
|
||||
10
app/src/debug/res/values/colors.xml
Normal file
10
app/src/debug/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug-only launcher-icon background. Production is slate (#5C6B7A); the
|
||||
debug build paints the adaptive-icon background burnt orange instead, so the
|
||||
debug icon reads at a glance as "not the real app" on the home screen. The
|
||||
off-white foreground mark contrasts on both. See drawable/ic_launcher_background.
|
||||
-->
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFB23B00</color>
|
||||
</resources>
|
||||
10
app/src/debug/res/values/strings.xml
Normal file
10
app/src/debug/res/values/strings.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug-build resource overrides. Merged on top of src/main for the `debug`
|
||||
build type only (release/releaseTest keep the production values), so the
|
||||
debug app is unmistakable on the launcher: its own label, alongside the
|
||||
real app thanks to the `.debug` applicationId suffix.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Calendula Debug</string>
|
||||
</resources>
|
||||
@@ -59,6 +59,35 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Be selectable as the system calendar app. Android has no API for
|
||||
an app to make itself the default, so registering the filters
|
||||
launchers and the OS use is what lets the user pick Calendula
|
||||
from the system chooser when a date action fires (issue #9).
|
||||
APP_CALENDAR is the "open the calendar app" action; the VIEW
|
||||
filters below catch a tapped date. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.APP_CALENDAR" />
|
||||
</intent-filter>
|
||||
<!-- A launcher/clock date tap fires ACTION_VIEW on the provider's
|
||||
time Uri (content://com.android.calendar/time/<epochMillis>);
|
||||
some surfaces use the time/epoch mime type. We open the day view
|
||||
on that date (MainActivity.calendarTimeDateOrNull). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data
|
||||
android:scheme="content"
|
||||
android:host="com.android.calendar"
|
||||
android:pathPrefix="/time" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="time/epoch" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
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.getValue
|
||||
@@ -29,12 +30,16 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
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.common.DebugRibbon
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@@ -98,19 +103,24 @@ class MainActivity : AppCompatActivity() {
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
// mistaken for the production app; compiled out of release.
|
||||
if (BuildConfig.DEBUG) DebugRibbon()
|
||||
}
|
||||
pendingCrashReport?.let { report ->
|
||||
CrashReportDialog(
|
||||
@@ -156,10 +166,39 @@ class MainActivity : AppCompatActivity() {
|
||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
else -> null
|
||||
} ?: return null
|
||||
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
||||
// it's a navigation, not a file to import, so [navRequestOrNull] owns it.
|
||||
if (uri.host == CALENDAR_PROVIDER_HOST) return null
|
||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||
}
|
||||
|
||||
/**
|
||||
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
||||
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||
* <epochMillis>`. Null for any other intent. The matching manifest filter is
|
||||
* what lets users pick Calendula from the system calendar chooser (issue #9).
|
||||
*/
|
||||
private fun Intent.calendarTimeDateOrNull(): LocalDate? {
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val uri = data ?: return null
|
||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments.firstOrNull() != "time") return null
|
||||
val millis = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||
return Instant.fromEpochMilliseconds(millis)
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
}
|
||||
|
||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
||||
// An external date tap (launcher/clock) has no widget source, so it opens
|
||||
// the day view rooted over the default home view (OpenDate source = null).
|
||||
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
||||
// A widget header tap: open a top-level view with no date drill-in. The
|
||||
// empty string carried by [openViewIntent] means "the default home view".
|
||||
if (hasExtra(EXTRA_OPEN_VIEW)) {
|
||||
val name = getStringExtra(EXTRA_OPEN_VIEW).orEmpty()
|
||||
return WidgetNavRequest.OpenView(CalendarView.entries.firstOrNull { it.name == name })
|
||||
}
|
||||
val source = sourceViewOrNull()
|
||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||
return when {
|
||||
@@ -201,12 +240,20 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
// The calendar provider's authority/host. A date tap arrives as
|
||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
||||
|
||||
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
||||
|
||||
// A widget header tap asking to open a top-level view (no date drill-in).
|
||||
// Its value is the target [CalendarView] name, or "" for the default view.
|
||||
private const val EXTRA_OPEN_VIEW = "de.jeanlucmakiola.calendula.extra.OPEN_VIEW"
|
||||
|
||||
// The [CalendarView] (by name) of the widget a launch came from. Roots the
|
||||
// in-app back stack in that view; absent for non-widget launches (reminders).
|
||||
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
|
||||
@@ -270,5 +317,19 @@ class MainActivity : AppCompatActivity() {
|
||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the app on a top-level [view] with no date drill-in — a widget
|
||||
* header tap. A null [view] opens the user's default home view (the agenda
|
||||
* widget's "Upcoming" title); a concrete view roots there over the default
|
||||
* home (the month widget's month/year title → [CalendarView.Month]). The
|
||||
* per-view data URI keeps distinct headers' PendingIntents from collapsing.
|
||||
*/
|
||||
fun openViewIntent(context: Context, view: CalendarView?): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
data = "calendula://view/${view?.name ?: "default"}".toUri()
|
||||
putExtra(EXTRA_OPEN_VIEW, view?.name ?: "")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,8 +118,14 @@ internal fun buildEventUpdateValues(
|
||||
* provider clone the series row and apply these on top. Unlike the series
|
||||
* update there is no dirty check — the exception is a fresh row, so every
|
||||
* form-backed column is written (empty optionals as explicit NULLs, since the
|
||||
* clone starts from the parent's values). An exception is a single event:
|
||||
* DTEND, never RRULE/DURATION.
|
||||
* clone starts from the parent's values).
|
||||
*
|
||||
* The occurrence's length travels as DURATION, never DTEND: the provider
|
||||
* rejects DTEND on an exception outright (`CalendarProvider2`:
|
||||
* "Exceptions can't overwrite dtend") and derives the instance end from
|
||||
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
|
||||
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
|
||||
* "only this event" fail on-device (Codeberg #16).
|
||||
*/
|
||||
internal fun buildOccurrenceExceptionValues(
|
||||
form: EventForm,
|
||||
@@ -131,7 +137,7 @@ internal fun buildOccurrenceExceptionValues(
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
|
||||
@@ -38,6 +38,19 @@ sealed interface WeekStartPref {
|
||||
*/
|
||||
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
|
||||
|
||||
/**
|
||||
* How the Agenda screen treats events that have already finished today: [SHOW]
|
||||
* leaves them as-is (the historical behaviour), [DIM] fades them, [HIDE] drops
|
||||
* them from the list entirely.
|
||||
*/
|
||||
enum class PastEventDisplay { SHOW, DIM, HIDE }
|
||||
|
||||
/** Parse a stored [PastEventDisplay.name]; unknown/null falls back to [default]. */
|
||||
fun parsePastEventDisplay(
|
||||
stored: String?,
|
||||
default: PastEventDisplay = PastEventDisplay.SHOW,
|
||||
): PastEventDisplay = PastEventDisplay.entries.firstOrNull { it.name == stored } ?: default
|
||||
|
||||
/**
|
||||
* Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the
|
||||
* device's `DateFormat.is24HourFormat` value).
|
||||
@@ -116,6 +129,31 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How the Agenda screen treats events that already ended today. Defaults to
|
||||
* [PastEventDisplay.SHOW] — the historical behaviour; users opt into dimming
|
||||
* or hiding.
|
||||
*/
|
||||
val pastEventDisplay: Flow<PastEventDisplay> = store.data.map { prefs ->
|
||||
prefs[PAST_EVENT_DISPLAY_KEY].toEnum(PastEventDisplay.SHOW)
|
||||
}
|
||||
|
||||
suspend fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||
store.edit { it[PAST_EVENT_DISPLAY_KEY] = mode.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the month/week grids fade events that have already finished.
|
||||
* Defaults to OFF — independent of the Agenda's [pastEventDisplay].
|
||||
*/
|
||||
val dimCompletedEvents: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[DIM_COMPLETED_EVENTS_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setDimCompletedEvents(enabled: Boolean) {
|
||||
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||
@@ -182,6 +220,20 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opening the new-event form focuses the title field and raises the
|
||||
* keyboard straight away (issue #10). Default ON — a new event almost always
|
||||
* gets a title, so this saves a tap; users who set the time/calendar first
|
||||
* can turn it off. Only the create form auto-focuses — editing never does.
|
||||
*/
|
||||
val autofocusEventTitle: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[AUTOFOCUS_EVENT_TITLE_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setAutofocusEventTitle(enabled: Boolean) {
|
||||
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||
* for users whose only calendar app this is, reminders are essential; the
|
||||
@@ -224,35 +276,36 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* The default reminder lead time (minutes before start) prefilled on new
|
||||
* **timed** events. `null` = no default reminder — the prior behaviour, kept
|
||||
* as the factory default so existing users aren't surprised by reminders they
|
||||
* never asked for. Stored as a string so "none" is distinct from a numeric
|
||||
* value (and from an unset key, which is also "none"). Per-calendar overrides
|
||||
* in [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||
* The default reminder lead times (minutes before start) prefilled on new
|
||||
* **timed** events. The empty list = no default reminder — the prior
|
||||
* behaviour, kept as the factory default so existing users aren't surprised by
|
||||
* reminders they never asked for. Stored as a comma-joined list of minutes, or
|
||||
* "none"/empty for no reminder (also the unset state). A legacy single value
|
||||
* ("30") parses transparently to a one-element list. Per-calendar overrides in
|
||||
* [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
||||
*/
|
||||
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
|
||||
val defaultReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_REMINDER_KEY].toReminderList()
|
||||
}
|
||||
|
||||
suspend fun setDefaultReminderMinutes(minutes: Int?) {
|
||||
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||
suspend fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||
store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The default reminder lead time prefilled on new **all-day** events, in
|
||||
* The default reminder lead times prefilled on new **all-day** events, in
|
||||
* minutes before the start of the day. All-day events want day-scale lead
|
||||
* times ("1 day before"), so they have their own default rather than reusing
|
||||
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
|
||||
* to all-day events — they always use this global value.
|
||||
* the timed one. Empty list = no default. Per-calendar overrides do **not**
|
||||
* apply to all-day events — they always use this global value.
|
||||
*/
|
||||
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
|
||||
val defaultAllDayReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList()
|
||||
}
|
||||
|
||||
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,13 +342,13 @@ class SettingsPrefs @Inject constructor(
|
||||
/**
|
||||
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
||||
* keyed by calendar id. A calendar **present** in the map overrides the global
|
||||
* timed default for its new events: a `null` value means "no reminder", an int
|
||||
* means that lead time. A calendar **absent** from the map inherits the global
|
||||
* default. Serialised as `id=value;id=value`, with `none` for an explicit
|
||||
* no-reminder override. (All-day events ignore this and use
|
||||
* [defaultAllDayReminderMinutes].)
|
||||
* timed default for its new events: an **empty** list means "no reminder", a
|
||||
* non-empty list means those lead times. A calendar **absent** from the map
|
||||
* inherits the global default. Serialised as `id=value;id=value`, where a value
|
||||
* is a comma-joined minute list or `none` for an explicit no-reminder override.
|
||||
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
||||
*/
|
||||
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
@@ -312,7 +365,7 @@ class SettingsPrefs @Inject constructor(
|
||||
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
||||
* inherit the global all-day default; present null = no reminder).
|
||||
*/
|
||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
@@ -390,8 +443,11 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||
@@ -440,43 +496,46 @@ sealed interface CalendarReminderOverride {
|
||||
data object Inherit : CalendarReminderOverride
|
||||
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
||||
data object None : CalendarReminderOverride
|
||||
/** A specific lead time in minutes before the event start. */
|
||||
data class Minutes(val minutes: Int) : CalendarReminderOverride
|
||||
/** Specific lead times in minutes before the event start (non-empty). */
|
||||
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead time to prefill on a new event: the matching per-calendar override
|
||||
* The lead times to prefill on a new event: the matching per-calendar override
|
||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
||||
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
|
||||
* it can be unit-tested.
|
||||
* events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder.
|
||||
* Pure so it can be unit-tested.
|
||||
*/
|
||||
fun resolveDefaultReminder(
|
||||
timedGlobal: Int?,
|
||||
allDayGlobal: Int?,
|
||||
timedOverrides: Map<Long, Int?>,
|
||||
allDayOverrides: Map<Long, Int?>,
|
||||
timedGlobal: List<Int>,
|
||||
allDayGlobal: List<Int>,
|
||||
timedOverrides: Map<Long, List<Int>>,
|
||||
allDayOverrides: Map<Long, List<Int>>,
|
||||
calendarId: Long?,
|
||||
isAllDay: Boolean,
|
||||
): Int? {
|
||||
): List<Int> {
|
||||
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
||||
val global = if (isAllDay) allDayGlobal else timedGlobal
|
||||
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
||||
overrides[calendarId]
|
||||
overrides.getValue(calendarId)
|
||||
} else {
|
||||
global
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
|
||||
private fun MutableMap<Long, Int?>.applyOverride(
|
||||
/**
|
||||
* Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
|
||||
* key; [None] and an empty [Minutes] both store the empty list).
|
||||
*/
|
||||
private fun MutableMap<Long, List<Int>>.applyOverride(
|
||||
calendarId: Long,
|
||||
override: CalendarReminderOverride,
|
||||
) {
|
||||
when (override) {
|
||||
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||
CalendarReminderOverride.None -> put(calendarId, null)
|
||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
|
||||
CalendarReminderOverride.None -> put(calendarId, emptyList())
|
||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,24 +561,36 @@ private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) {
|
||||
private const val NONE = "none"
|
||||
private const val ENTRY_SEP = ";"
|
||||
private const val KEY_VALUE_SEP = "="
|
||||
private const val LIST_SEP = ","
|
||||
|
||||
private fun String?.toReminderMinutes(): Int? = when (this) {
|
||||
null, "", NONE -> null
|
||||
else -> toIntOrNull()
|
||||
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
||||
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||
|
||||
/**
|
||||
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
||||
* list; a comma-joined list → its minutes; a legacy single value ("30") → a
|
||||
* one-element list. Non-numeric parts are dropped defensively.
|
||||
*/
|
||||
private fun String?.toReminderList(): List<Int> = when {
|
||||
this == null || isEmpty() || this == NONE -> emptyList()
|
||||
else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders()
|
||||
}
|
||||
|
||||
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
||||
/** Serialise lead times for storage: "none" when empty, else comma-joined. */
|
||||
private fun List<Int>.toStoredReminders(): String =
|
||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||
|
||||
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
|
||||
if (stored.isNullOrBlank()) return emptyMap()
|
||||
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
|
||||
id to value
|
||||
id to parts[1].toReminderList()
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
|
||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
|
||||
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
|
||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
|
||||
|
||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.content.pm.PackageManager
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -16,6 +17,17 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The due alerts that should actually surface as notifications: everything
|
||||
* except alerts whose calendar the user has disabled in-app (mirroring the
|
||||
* event filtering in CalendarRepositoryImpl). The caller still marks the full
|
||||
* due set fired, so suppressed alerts are not re-broadcast by the provider.
|
||||
*/
|
||||
internal fun postableAlerts(
|
||||
due: List<ReminderAlert>,
|
||||
disabledCalendarIds: Set<Long>,
|
||||
): List<ReminderAlert> = due.filterNot { it.calendarId in disabledCalendarIds }
|
||||
|
||||
/**
|
||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||
* visible notifications (the Etar model — the provider broadcasts
|
||||
@@ -32,6 +44,7 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
@Inject lateinit var alertStore: ReminderAlertStore
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||
@@ -46,7 +59,11 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
if (settingsPrefs.remindersEnabled.first()) {
|
||||
val now = System.currentTimeMillis()
|
||||
val due = alertStore.dueAlerts(now)
|
||||
due.forEach { notifier.post(it) }
|
||||
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||
// Suppress reminders for disabled calendars, but still mark
|
||||
// every due alert fired so the provider stops re-broadcasting
|
||||
// the suppressed ones.
|
||||
postableAlerts(due, disabled).forEach { notifier.post(it) }
|
||||
alertStore.markFired(due.map { it.alertId }, now)
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -75,6 +75,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
|
||||
private const val EXTRA_ALERT_ID = "alert_id"
|
||||
private const val EXTRA_EVENT_ID = "event_id"
|
||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||
private const val EXTRA_BEGIN = "begin"
|
||||
private const val EXTRA_END = "end"
|
||||
private const val EXTRA_TITLE = "title"
|
||||
@@ -87,6 +88,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
this.action = action
|
||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
putExtra(EXTRA_END, alert.endMillis)
|
||||
putExtra(EXTRA_TITLE, alert.title)
|
||||
@@ -113,6 +115,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
return ReminderAlert(
|
||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||
endMillis = intent.getLongExtra(EXTRA_END, 0L),
|
||||
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
||||
|
||||
@@ -16,6 +16,7 @@ import javax.inject.Singleton
|
||||
data class ReminderAlert(
|
||||
val alertId: Long,
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||
@@ -66,11 +67,12 @@ class AndroidReminderAlertStore @Inject constructor(
|
||||
ReminderAlert(
|
||||
alertId = c.getLong(0),
|
||||
eventId = c.getLong(1),
|
||||
beginMillis = c.getLong(2),
|
||||
endMillis = c.getLong(3),
|
||||
title = c.getString(4).orEmpty(),
|
||||
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(6) == 1,
|
||||
calendarId = c.getLong(2),
|
||||
beginMillis = c.getLong(3),
|
||||
endMillis = c.getLong(4),
|
||||
title = c.getString(5).orEmpty(),
|
||||
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(7) == 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -102,6 +104,7 @@ class AndroidReminderAlertStore @Inject constructor(
|
||||
val PROJECTION = arrayOf(
|
||||
CalendarContract.CalendarAlerts._ID,
|
||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
||||
CalendarContract.CalendarAlerts.BEGIN,
|
||||
CalendarContract.CalendarAlerts.END,
|
||||
CalendarContract.CalendarAlerts.TITLE,
|
||||
|
||||
@@ -40,6 +40,14 @@ data class EventInstance(
|
||||
val location: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether this event has finished relative to [now] — its end is at or before
|
||||
* the current instant. An in-progress event (already started but not yet ended)
|
||||
* is *not* considered ended. All-day events end at the exclusive next-midnight,
|
||||
* so they only count as ended once their day is fully over.
|
||||
*/
|
||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
|
||||
@@ -196,7 +196,9 @@ fun CalendarHost(
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = req.dateIso
|
||||
viewStack = viewBaseStack(defaultView, req.source).drillToDay()
|
||||
// No widget source (an external date tap) roots over the default
|
||||
// home view, so backing out of the day returns home then exits.
|
||||
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.OpenEvent -> {
|
||||
@@ -210,6 +212,17 @@ fun CalendarHost(
|
||||
detailKey = key
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.OpenView -> {
|
||||
// A widget header tap: land on a top-level view with no date
|
||||
// drill-in. Reveal it by dropping any covering overlay, then root
|
||||
// the stack on the target (null → the default home) over the
|
||||
// default home — so backing out returns to the default, then exits.
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = null
|
||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.Create -> {
|
||||
// External "new event" entries (QS tile / launcher shortcut /
|
||||
// widget) must land on top of whatever is open — the form overlay
|
||||
|
||||
@@ -13,8 +13,13 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
* detail-key channel and leave the base view untouched.)
|
||||
*/
|
||||
sealed interface WidgetNavRequest {
|
||||
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
|
||||
data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest
|
||||
/**
|
||||
* Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over
|
||||
* [source]. A null [source] means the request came from outside the app (a
|
||||
* launcher/clock date tap, issue #9) rather than a widget, so it roots over
|
||||
* the default home view instead of a widget's view.
|
||||
*/
|
||||
data class OpenDate(val dateIso: String, val source: CalendarView?) : WidgetNavRequest
|
||||
|
||||
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
|
||||
data class OpenEvent(
|
||||
@@ -26,4 +31,13 @@ sealed interface WidgetNavRequest {
|
||||
|
||||
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||
|
||||
/**
|
||||
* Open the app rooted on a top-level [view] with no date drill-in — a widget
|
||||
* header tap. A null [view] means "the user's default home view" (the agenda
|
||||
* widget's "Upcoming" title, issue #20); a concrete view roots there over the
|
||||
* default home (the month widget's month/year title → [CalendarView.Month],
|
||||
* issue #18), so backing out returns to the default view, then exits.
|
||||
*/
|
||||
data class OpenView(val view: CalendarView?) : WidgetNavRequest
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ 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.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -53,7 +54,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||
@@ -61,12 +64,14 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
@@ -96,6 +101,7 @@ fun AgendaScreen(
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -178,6 +184,7 @@ fun AgendaScreen(
|
||||
}
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
@@ -275,6 +282,7 @@ private fun AgendaRangeBanner(
|
||||
@Composable
|
||||
private fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
pastDisplay: PastEventDisplay,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -284,19 +292,42 @@ private fun AgendaContent(
|
||||
is AgendaUiState.Failure -> Box(modifier) {
|
||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||
}
|
||||
is AgendaUiState.Success ->
|
||||
if (state.days.isEmpty()) {
|
||||
is AgendaUiState.Success -> {
|
||||
val now by rememberCurrentMinute()
|
||||
// Hiding drops finished events — and any day they leave empty; dimming
|
||||
// keeps them but fades the row. Recomputed each minute so events fall
|
||||
// away (or fade) as they end while the screen stays open.
|
||||
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
state.days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
}
|
||||
} else {
|
||||
state.days
|
||||
}
|
||||
if (days.isEmpty()) {
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
|
||||
AgendaList(
|
||||
days = days,
|
||||
today = state.today,
|
||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||
now = now,
|
||||
onEventClick = onEventClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AgendaList(
|
||||
state: AgendaUiState.Success,
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -305,9 +336,9 @@ private fun AgendaList(
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
days.forEach { day ->
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = state.today)
|
||||
AgendaDayHeader(date = day.date, today = today)
|
||||
}
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
@@ -316,6 +347,7 @@ private fun AgendaList(
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = calendarAnimateItem(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
@@ -348,13 +380,14 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = modifier,
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event),
|
||||
position = position,
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
@@ -53,6 +54,18 @@ class AgendaViewModel @Inject constructor(
|
||||
private val weekStartDay = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||
|
||||
/**
|
||||
* How to treat events that already ended today (show / dim / hide). A display
|
||||
* concern only, so it rides alongside the data state rather than re-querying;
|
||||
* the screen combines it with a per-minute "now" to fade or drop past rows.
|
||||
*/
|
||||
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = PastEventDisplay.SHOW,
|
||||
)
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
/**
|
||||
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
|
||||
* the app. Deliberately a stark, un-themed marker (not a product component) so a
|
||||
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
|
||||
* call site so it never reaches a release build. Non-interactive: it's a plain
|
||||
* label with no pointer handler, so taps fall through to whatever is beneath it.
|
||||
*
|
||||
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
|
||||
* so it overlays the UI.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.DebugRibbon() {
|
||||
Text(
|
||||
text = "DEBUG",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.zIndex(1f)
|
||||
// Push the band out so its midline crosses the very corner, then
|
||||
// rotate it to the classic 45° ribbon.
|
||||
.offset(x = 36.dp, y = 24.dp)
|
||||
.rotate(45f)
|
||||
.background(Color(0xFFB23B00))
|
||||
.width(140.dp)
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ private val LineThickness = 2.dp
|
||||
* the middle of a minute. Drives the "now" line in the day/week grids.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberCurrentMinute(): State<Instant> {
|
||||
fun rememberCurrentMinute(): State<Instant> {
|
||||
val instant = remember { mutableStateOf(Clock.System.now()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The cut-off instant before which a calendar event counts as "completed" and is
|
||||
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
|
||||
* the "dim completed events" setting is on, or `null` when it is off (nothing
|
||||
* dims). Provided per grid screen so only the event chips that read it recompose
|
||||
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
|
||||
*/
|
||||
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
||||
|
||||
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||
const val EventDimAlpha = 0.4f
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -26,6 +27,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -111,10 +113,15 @@ fun <T> OptionPicker(
|
||||
}
|
||||
|
||||
/**
|
||||
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
|
||||
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
|
||||
* a "Custom" row that expands an inline number field plus a segmented unit
|
||||
* selector. Returns the choice as a [CalendarReminderOverride].
|
||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
||||
* independently, so a default can carry several reminders ("1 week before" *and*
|
||||
* "on the day"). A per-calendar picker ([allowInherit]) gains an exclusive "Use
|
||||
* default reminder" row above the list; choosing it defers to the global default
|
||||
* and clears the checked times. With nothing checked and inherit off, the choice
|
||||
* is [CalendarReminderOverride.None] (an explicit no-reminder). A "Custom" row
|
||||
* expands an inline number field plus a unit selector to add an arbitrary lead
|
||||
* time to the set. Changes apply live via [onSelect]; the user leaves via back.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderDefaultPicker(
|
||||
@@ -125,57 +132,74 @@ fun ReminderDefaultPicker(
|
||||
onSelect: (CalendarReminderOverride) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
|
||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
||||
// Optimistic local state: the chosen override is authoritative while the
|
||||
// picker is open, so quick successive toggles compose on each other instead
|
||||
// of racing the round-trip through the settings flow (which would drop a
|
||||
// toggle made before the previous write echoes back). Seeded once on open;
|
||||
// the picker leaves composition on dismiss, so reopening re-reads [selected].
|
||||
var current by remember { mutableStateOf(selected) }
|
||||
val inherits = current is CalendarReminderOverride.Inherit
|
||||
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
// Presets plus any selected custom (non-preset) values, each as its own row.
|
||||
val rows = presets + selectedMinutes.filter { it !in presets }.sorted()
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable { mutableStateOf(seed.first) }
|
||||
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
||||
var amountText by rememberSaveable { mutableStateOf("") }
|
||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||
|
||||
val options = buildList {
|
||||
if (allowInherit) add(CalendarReminderOverride.Inherit)
|
||||
add(CalendarReminderOverride.None)
|
||||
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
|
||||
fun apply(override: CalendarReminderOverride) {
|
||||
current = override
|
||||
onSelect(override)
|
||||
}
|
||||
val rowCount = options.size + 1 // + the custom row
|
||||
// Emit the override for a new set of minutes: empty collapses to None, which
|
||||
// (with inherit unchosen) reads as an explicit "no reminder".
|
||||
fun emit(minutes: List<Int>) {
|
||||
val norm = minutes.distinct().sorted()
|
||||
apply(
|
||||
if (norm.isEmpty()) {
|
||||
CalendarReminderOverride.None
|
||||
} else {
|
||||
CalendarReminderOverride.Minutes(norm)
|
||||
},
|
||||
)
|
||||
}
|
||||
fun toggle(minute: Int) =
|
||||
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
// Exclusive "use default" choice (per-calendar only): its own group above
|
||||
// the multi-select list, since inheriting is mutually exclusive with
|
||||
// picking specific times.
|
||||
if (allowInherit) {
|
||||
GroupedRow(
|
||||
title = reminderOverrideLabel(option),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
title = stringResource(R.string.reminder_use_default),
|
||||
position = Position.Alone,
|
||||
selected = inherits,
|
||||
trailing = if (inherits) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
},
|
||||
onClick = { apply(CalendarReminderOverride.Inherit) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
val rowCount = rows.size + 1 // + the custom row
|
||||
rows.forEachIndexed { index, minute ->
|
||||
val checked = minute in selectedMinutes
|
||||
GroupedRow(
|
||||
title = reminderLeadTimeLabel(minute),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = checked,
|
||||
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||
onClick = { toggle(minute) },
|
||||
)
|
||||
}
|
||||
// When expanded, the Custom row connects downward into the editor card
|
||||
// so the two read as one grouped container (the per-calendar pattern).
|
||||
GroupedRow(
|
||||
title = if (customSelected) {
|
||||
stringResource(
|
||||
R.string.reminder_custom_with_value,
|
||||
reminderLeadTimeLabel(selectedMinutes!!),
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.event_edit_reminder_custom)
|
||||
},
|
||||
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
title = stringResource(R.string.event_edit_reminder_custom),
|
||||
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
@@ -189,8 +213,9 @@ fun ReminderDefaultPicker(
|
||||
unit = unit,
|
||||
onUnitChange = { unit = it },
|
||||
onConfirm = { minutes ->
|
||||
onSelect(CalendarReminderOverride.Minutes(minutes))
|
||||
onDismiss()
|
||||
emit(selectedMinutes + minutes)
|
||||
amountText = ""
|
||||
customExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -428,18 +453,3 @@ fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
||||
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
||||
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
||||
}
|
||||
|
||||
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
|
||||
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
|
||||
minutes == null -> "" to ReminderUnit.Minutes
|
||||
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
|
||||
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
|
||||
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
|
||||
else -> minutes.toString() to ReminderUnit.Minutes
|
||||
}
|
||||
|
||||
@@ -81,6 +81,8 @@ 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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
@@ -479,6 +481,17 @@ private fun EventEditContent(
|
||||
?: MaterialTheme.colorScheme.primary
|
||||
val gap = 12.dp
|
||||
|
||||
// Issue #10: on a fresh create form (setting on, nothing typed yet) focus the
|
||||
// title and raise the keyboard so the user can type straight away. Keyed on
|
||||
// Unit so it runs once per open — not on every keystroke — and guarded so
|
||||
// editing or an imported/prefilled form never grabs focus.
|
||||
val titleFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
if (state.autofocusTitle && !state.isEditing && form.title.isBlank()) {
|
||||
runCatching { titleFocusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
// Per-event colour applicability for the resolved calendar:
|
||||
// - palette calendars (Google, …) and local calendars always support it;
|
||||
// - synced calendars with no palette only when the user opted in, and even
|
||||
@@ -506,6 +519,10 @@ private fun EventEditContent(
|
||||
placeholder = stringResource(R.string.event_edit_title_hint),
|
||||
textStyle = MaterialTheme.typography.headlineMedium
|
||||
.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.focusRequester(titleFocusRequester),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
|
||||
@@ -29,6 +29,12 @@ data class EventEditUiState(
|
||||
val hiddenFields: List<EventFormField> = emptyList(),
|
||||
/** True while editing an existing event (the calendar is then fixed). */
|
||||
val isEditing: Boolean = false,
|
||||
/**
|
||||
* Whether to focus the title and raise the keyboard when this is a fresh
|
||||
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
||||
* on it for a new event with an empty title, never when editing/importing.
|
||||
*/
|
||||
val autofocusTitle: Boolean = true,
|
||||
/**
|
||||
* True while an edit changed the recurrence rule — the save-scope dialog
|
||||
* then drops "only this event" (an exception row can't carry a rule).
|
||||
|
||||
@@ -108,10 +108,10 @@ class EventEditViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
private data class ReminderDefaults(
|
||||
val timed: Int?,
|
||||
val allDay: Int?,
|
||||
val timedOverrides: Map<Long, Int?>,
|
||||
val allDayOverrides: Map<Long, Int?>,
|
||||
val timed: List<Int>,
|
||||
val allDay: List<Int>,
|
||||
val timedOverrides: Map<Long, List<Int>>,
|
||||
val allDayOverrides: Map<Long, List<Int>>,
|
||||
)
|
||||
|
||||
private data class ExternalInputs(
|
||||
@@ -119,6 +119,7 @@ class EventEditViewModel @Inject constructor(
|
||||
val lastUsed: Long?,
|
||||
val defaultFields: Set<EventFormField>,
|
||||
val allowColorOnUnsupported: Boolean,
|
||||
val autofocusTitle: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -160,6 +161,7 @@ class EventEditViewModel @Inject constructor(
|
||||
prefs.lastUsedCalendarId,
|
||||
settingsPrefs.defaultFormFields,
|
||||
settingsPrefs.allowColorOnUnsupportedCalendars,
|
||||
settingsPrefs.autofocusEventTitle,
|
||||
::ExternalInputs,
|
||||
).flowOn(io),
|
||||
colorPalette,
|
||||
@@ -178,6 +180,7 @@ class EventEditViewModel @Inject constructor(
|
||||
visibleFields = visibleFields,
|
||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||
isEditing = local.editTarget != null,
|
||||
autofocusTitle = external.autofocusTitle,
|
||||
// A modified-occurrence exception can't carry its own rule, so
|
||||
// the scope dialog drops "only this event" after a rule change.
|
||||
recurrenceChanged = local.editTarget != null &&
|
||||
@@ -257,7 +260,7 @@ class EventEditViewModel @Inject constructor(
|
||||
// Re-check after suspending: bail if the form closed or the user edited.
|
||||
val form = _form.value ?: return@launch
|
||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||
val default = resolveDefaultReminder(
|
||||
val reminders = resolveDefaultReminder(
|
||||
timedGlobal = defaults.timed,
|
||||
allDayGlobal = defaults.allDay,
|
||||
timedOverrides = defaults.timedOverrides,
|
||||
@@ -265,7 +268,6 @@ class EventEditViewModel @Inject constructor(
|
||||
calendarId = targetId,
|
||||
isAllDay = form.isAllDay,
|
||||
)
|
||||
val reminders = listOfNotNull(default)
|
||||
_form.value = form.copy(reminders = reminders)
|
||||
// Surface the section so an auto-applied default is visible and
|
||||
// removable, even when Reminders isn't a default-shown field.
|
||||
|
||||
@@ -36,6 +36,8 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -44,6 +46,7 @@ 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.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -60,10 +63,14 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
@@ -97,6 +104,14 @@ fun MonthScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
// screen while the setting is off (it stays null regardless of the tick).
|
||||
val nowState = rememberCurrentMinute()
|
||||
val dimCutoff by remember(dimCompleted) {
|
||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -193,14 +208,16 @@ fun MonthScreen(
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
WeekdayHeader(weekStart = weekStart)
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,6 +575,8 @@ private fun MonthBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
@@ -565,7 +584,7 @@ private fun MonthBar(
|
||||
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(event.color, dark), shape)
|
||||
.padding(horizontal = 4.dp)
|
||||
.semantics { contentDescription = title },
|
||||
|
||||
@@ -59,6 +59,14 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = DayOfWeek.MONDAY,
|
||||
)
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Gavel
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
@@ -85,6 +86,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
@@ -474,6 +476,7 @@ private fun AppearanceScreen(
|
||||
var showDefaultView by remember { mutableStateOf(false) }
|
||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||
var showPastEvents by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
@@ -532,7 +535,7 @@ private fun AppearanceScreen(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_hour_lines),
|
||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||
position = Position.Bottom,
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showHourLines,
|
||||
@@ -541,16 +544,36 @@ private fun AppearanceScreen(
|
||||
},
|
||||
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_dim_completed),
|
||||
summary = stringResource(R.string.settings_dim_completed_summary),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.dimCompletedEvents,
|
||||
onCheckedChange = viewModel::setDimCompletedEvents,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Agenda
|
||||
// Agenda — the only group labelled, so its agenda-specific rows are easy
|
||||
// to pick out among the otherwise unheadered Appearance settings.
|
||||
SectionHeader(stringResource(R.string.settings_agenda_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
summary = agendaRangeLabel(state.agendaScreenRange),
|
||||
position = Position.Top,
|
||||
onClick = { showAgendaScreenRange = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_past_events),
|
||||
summary = pastEventDisplayLabel(state.pastEventDisplay),
|
||||
position = Position.Middle,
|
||||
onClick = { showPastEvents = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_widget_range),
|
||||
summary = agendaRangeLabel(state.agendaWidgetRange),
|
||||
@@ -619,6 +642,16 @@ private fun AppearanceScreen(
|
||||
onDismiss = { showTimeFormat = false },
|
||||
)
|
||||
}
|
||||
if (showPastEvents) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_past_events),
|
||||
options = PastEventDisplay.entries,
|
||||
selected = state.pastEventDisplay,
|
||||
label = { pastEventDisplayLabel(it) },
|
||||
onSelect = viewModel::setPastEventDisplay,
|
||||
onDismiss = { showPastEvents = false },
|
||||
)
|
||||
}
|
||||
if (showDefaultView) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
@@ -673,6 +706,30 @@ private fun EventFormScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-focus the title on a new event (issue #10) — on by default, since
|
||||
// most events get a title; raising the keyboard saves a tap. Off lets you
|
||||
// set the time/calendar first without the keyboard in the way.
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_autofocus_title),
|
||||
summary = stringResource(R.string.settings_autofocus_title_hint),
|
||||
position = Position.Alone,
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Keyboard,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.autofocusEventTitle,
|
||||
onCheckedChange = { viewModel.setAutofocusEventTitle(it) },
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
||||
)
|
||||
|
||||
// Per-event colour on calendars that publish no colour set (some
|
||||
// CalDAV) — off by default, with the honest caveat that the colour may
|
||||
// not survive their next sync. Local and palette calendars ignore it.
|
||||
@@ -912,7 +969,7 @@ private fun NotificationsScreen(
|
||||
presets = REMINDER_PRESETS,
|
||||
selected = state.defaultReminderMinutes.toReminderChoice(),
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) },
|
||||
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
|
||||
onDismiss = { showDefaultReminder = false },
|
||||
)
|
||||
}
|
||||
@@ -922,7 +979,7 @@ private fun NotificationsScreen(
|
||||
presets = ALLDAY_REMINDER_PRESETS,
|
||||
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) },
|
||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
|
||||
onDismiss = { showAllDayReminder = false },
|
||||
)
|
||||
}
|
||||
@@ -971,13 +1028,13 @@ private fun NotificationsScreen(
|
||||
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||
|
||||
/** A global default (null = none) as a picker choice for selection highlighting. */
|
||||
private fun Int?.toReminderChoice(): CalendarReminderOverride =
|
||||
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
||||
/** A global default (empty = none) as a picker choice for selection highlighting. */
|
||||
private fun List<Int>.toReminderChoice(): CalendarReminderOverride =
|
||||
if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
||||
|
||||
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
||||
private fun CalendarReminderOverride.toMinutesOrNull(): Int? =
|
||||
(this as? CalendarReminderOverride.Minutes)?.minutes
|
||||
private fun CalendarReminderOverride.toMinutesList(): List<Int> =
|
||||
(this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList()
|
||||
|
||||
/**
|
||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||
@@ -1052,27 +1109,33 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
||||
}
|
||||
|
||||
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
|
||||
private fun Map<Long, Int?>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||
private fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||
this[calendarId] == null -> CalendarReminderOverride.None
|
||||
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!)
|
||||
this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
||||
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId))
|
||||
}
|
||||
|
||||
/** Label for a global-default choice: null → "None", else the lead time. */
|
||||
/** Label for a global-default choice: empty → "None", else the lead times joined. */
|
||||
@Composable
|
||||
private fun reminderChoiceLabel(minutes: Int?): String =
|
||||
if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes)
|
||||
private fun reminderChoiceLabel(minutes: List<Int>): String {
|
||||
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
|
||||
// reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather
|
||||
// than a (non-composable) joinToString transform.
|
||||
val parts = ArrayList<String>(minutes.size)
|
||||
for (m in minutes) parts.add(reminderLeadTimeLabel(m))
|
||||
return parts.joinToString(", ")
|
||||
}
|
||||
|
||||
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||
@Composable
|
||||
private fun calendarOverrideSummary(
|
||||
choice: CalendarReminderOverride,
|
||||
globalDefault: Int?,
|
||||
globalDefault: List<Int>,
|
||||
): String = when (choice) {
|
||||
CalendarReminderOverride.Inherit ->
|
||||
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
||||
is CalendarReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1145,6 +1208,26 @@ private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
|
||||
},
|
||||
)
|
||||
|
||||
/** A small primary-coloured group label, matching the Calendars settings screen. */
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun pastEventDisplayLabel(mode: PastEventDisplay): String = stringResource(
|
||||
when (mode) {
|
||||
PastEventDisplay.SHOW -> R.string.settings_past_events_show
|
||||
PastEventDisplay.DIM -> R.string.settings_past_events_dim
|
||||
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun languageLabel(tag: String?): String =
|
||||
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
@@ -24,6 +25,10 @@ data class SettingsUiState(
|
||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
||||
val showHourLines: Boolean = false,
|
||||
/** How the Agenda screen treats events that already ended today. */
|
||||
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||
/** Whether the month/week grids fade events that have already finished. */
|
||||
val dimCompletedEvents: Boolean = false,
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||
/** How far ahead the agenda widget shows events (v2.11). */
|
||||
@@ -34,27 +39,29 @@ data class SettingsUiState(
|
||||
val defaultView: CalendarView = CalendarView.Week,
|
||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||
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,
|
||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||
val remindersEnabled: Boolean = true,
|
||||
/**
|
||||
* The default reminder lead time (minutes) prefilled on new timed events;
|
||||
* null = no default reminder. Per-calendar overrides take precedence.
|
||||
* The default reminder lead times (minutes) prefilled on new timed events;
|
||||
* empty = no default reminder. Per-calendar overrides take precedence.
|
||||
*/
|
||||
val defaultReminderMinutes: Int? = null,
|
||||
/** The default reminder lead time prefilled on new all-day events; null = none. */
|
||||
val defaultAllDayReminderMinutes: Int? = null,
|
||||
val defaultReminderMinutes: List<Int> = emptyList(),
|
||||
/** The default reminder lead times prefilled on new all-day events; empty = none. */
|
||||
val defaultAllDayReminderMinutes: List<Int> = emptyList(),
|
||||
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
||||
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
||||
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
|
||||
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
|
||||
/**
|
||||
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
||||
* calendar present in the map overrides the global default (null value = no
|
||||
* calendar present in the map overrides the global default (empty value = no
|
||||
* reminder); absent = inherit the global default.
|
||||
*/
|
||||
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
@@ -20,6 +21,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
@@ -96,15 +98,24 @@ class SettingsViewModel @Inject constructor(
|
||||
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
||||
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
||||
},
|
||||
prefs.agendaShowRangeBar,
|
||||
) { base, defaults, overrides, views, showRangeBar ->
|
||||
combine(
|
||||
prefs.agendaShowRangeBar,
|
||||
prefs.autofocusEventTitle,
|
||||
prefs.pastEventDisplay,
|
||||
prefs.dimCompletedEvents,
|
||||
::MiscSettings,
|
||||
),
|
||||
) { base, defaults, overrides, views, misc ->
|
||||
base.copy(
|
||||
defaultView = views.defaultView,
|
||||
agendaScreenRange = views.agendaScreenRange,
|
||||
agendaWidgetRange = views.agendaWidgetRange,
|
||||
timeFormat = views.timeFormat,
|
||||
showHourLines = views.showHourLines,
|
||||
agendaShowRangeBar = showRangeBar,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
dimCompletedEvents = misc.dimCompletedEvents,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
@@ -128,15 +139,15 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
private data class ReminderDefaults(
|
||||
val allowColor: Boolean,
|
||||
val defaultReminder: Int?,
|
||||
val allDayReminder: Int?,
|
||||
val defaultReminder: List<Int>,
|
||||
val allDayReminder: List<Int>,
|
||||
val allDayReminderTime: Int,
|
||||
val snoozeMinutes: Int,
|
||||
)
|
||||
|
||||
private data class ReminderOverrides(
|
||||
val timed: Map<Long, Int?>,
|
||||
val allDay: Map<Long, Int?>,
|
||||
val timed: Map<Long, List<Int>>,
|
||||
val allDay: Map<Long, List<Int>>,
|
||||
val calendars: List<CalendarSource>,
|
||||
)
|
||||
|
||||
@@ -148,6 +159,13 @@ class SettingsViewModel @Inject constructor(
|
||||
val showHourLines: Boolean,
|
||||
)
|
||||
|
||||
private data class MiscSettings(
|
||||
val showRangeBar: Boolean,
|
||||
val autofocusEventTitle: Boolean,
|
||||
val pastEventDisplay: PastEventDisplay,
|
||||
val dimCompletedEvents: Boolean,
|
||||
)
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||
}
|
||||
@@ -201,6 +219,26 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
||||
}
|
||||
|
||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||
viewModelScope.launch {
|
||||
prefs.setPastEventDisplay(mode)
|
||||
// The agenda widget honours this setting too, so push it into each
|
||||
// instance's Glance state and recompose — same reliable-update path as
|
||||
// setAgendaWidgetRange (updateAll alone won't re-run the data preamble).
|
||||
widgetRefreshMutex.withLock {
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setDimCompletedEvents(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setDimCompletedEvents(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultView(view: CalendarView) {
|
||||
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||
}
|
||||
@@ -213,11 +251,15 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultReminderMinutes(minutes: Int?) {
|
||||
fun setAutofocusEventTitle(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||
}
|
||||
|
||||
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -55,6 +57,7 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -73,11 +76,15 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
@@ -129,6 +136,14 @@ fun WeekScreen(
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
// screen while the setting is off (it stays null regardless of the tick).
|
||||
val nowState = rememberCurrentMinute()
|
||||
val dimCutoff by remember(dimCompleted) {
|
||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -221,19 +236,21 @@ fun WeekScreen(
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -550,8 +567,10 @@ private fun AllDayBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
@@ -692,6 +711,7 @@ private fun DayColumnCard(
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
@@ -712,6 +732,7 @@ private fun DayColumnCard(
|
||||
private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -720,9 +741,26 @@ private fun EventBlock(
|
||||
val locale = currentLocale()
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
val showTime = block.endMin - block.startMin >= 45
|
||||
// Only full-width (non-overlapping) blocks that are tall enough show the time.
|
||||
// On narrow overlapping columns we drop it so the title can wrap to fill the
|
||||
// whole block, mirroring Google Calendar.
|
||||
val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1
|
||||
val density = LocalDensity.current
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// Wrap the title across as many lines as the block can fit (minus the 2.dp
|
||||
// top/bottom padding and the reserved time line) instead of clipping it to a
|
||||
// single character on slim, overlapping blocks.
|
||||
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
|
||||
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
@@ -732,18 +770,15 @@ private fun EventBlock(
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.85f),
|
||||
)
|
||||
if (showTime) {
|
||||
// Narrow columns can't fit "13:00–14:00" on one line, so let it
|
||||
// wrap to a second line (after the dash) instead of clipping the
|
||||
// end time.
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
)
|
||||
|
||||
@@ -50,6 +50,14 @@ class WeekViewModel @Inject constructor(
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
private val locale: Locale = Locale.getDefault()
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
@@ -24,6 +25,7 @@ import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* How far either side of today the month widget pre-loads. The displayed month
|
||||
@@ -52,6 +54,8 @@ sealed interface AgendaWidgetData {
|
||||
* range is sliced from this in the composition (read reactively from
|
||||
* Glance state), so a range change is pure recomposition rather than a
|
||||
* flaky widget session restart — the same approach the month widget uses.
|
||||
* Kept unfiltered: hiding/dimming finished events also happens reactively
|
||||
* in the composition, against [now], for the same reliable-update reason.
|
||||
*/
|
||||
val days: List<AgendaDay>,
|
||||
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||
@@ -60,6 +64,10 @@ sealed interface AgendaWidgetData {
|
||||
val weekStart: DayOfWeek,
|
||||
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
||||
val savedRange: AgendaRange,
|
||||
/** Saved past-event display pref — the fallback before Glance state is set. */
|
||||
val savedPastDisplay: PastEventDisplay,
|
||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||
val now: Instant,
|
||||
) : AgendaWidgetData
|
||||
}
|
||||
|
||||
@@ -108,6 +116,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
val ep = widgetEntryPoint()
|
||||
val prefs = ep.settingsPrefs()
|
||||
val savedRange = prefs.agendaWidgetRange.first()
|
||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||
// Load the widest selectable window once; the displayed range is sliced in
|
||||
// the composition from Glance state, so changing the range is a plain
|
||||
@@ -122,6 +131,8 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
is24Hour = is24Hour,
|
||||
weekStart = weekStart,
|
||||
savedRange = savedRange,
|
||||
savedPastDisplay = savedPastDisplay,
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,11 +43,15 @@ import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
@@ -79,6 +83,14 @@ import java.util.Locale
|
||||
*/
|
||||
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
||||
|
||||
/**
|
||||
* Per-instance Glance state key holding the past-event display mode (as
|
||||
* [PastEventDisplay.name]). Read reactively in the composition for the same
|
||||
* reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live
|
||||
* widget without depending on the `provideGlance` preamble re-running.
|
||||
*/
|
||||
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
||||
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
@@ -125,11 +137,26 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
// to the saved pref for a freshly placed widget), then the wide
|
||||
// pre-loaded window is sliced to it — pure recomposition.
|
||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||
val pastDisplay =
|
||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||
val rangeEnd = data.today.plus(
|
||||
range.dayCount(data.today, data.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
val visibleDays = data.days.filter { it.date <= rangeEnd }
|
||||
// Slice to the chosen range, then drop finished events (and any day
|
||||
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
||||
val visibleDays = data.days
|
||||
.filter { it.date <= rangeEnd }
|
||||
.let { days ->
|
||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
}
|
||||
} else {
|
||||
days
|
||||
}
|
||||
}
|
||||
if (visibleDays.isEmpty()) {
|
||||
WidgetMessage(R.string.agenda_empty_title)
|
||||
} else {
|
||||
@@ -143,7 +170,13 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
items(rows.size) { index ->
|
||||
when (val row = rows[index]) {
|
||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||
is AgendaRow.Event -> EventRow(row.event, dark, data.is24Hour)
|
||||
is AgendaRow.Event -> EventRow(
|
||||
event = row.event,
|
||||
dark = dark,
|
||||
is24Hour = data.is24Hour,
|
||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||
row.event.hasEnded(data.now),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,6 +193,8 @@ private fun AgendaHeader() {
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Tap the "Upcoming" title to open the app on the user's default view
|
||||
// (issue #20) — a null target resolves to the default home in the host.
|
||||
Text(
|
||||
text = context.getString(R.string.widget_agenda_title),
|
||||
style = TextStyle(
|
||||
@@ -167,7 +202,11 @@ private fun AgendaHeader() {
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
modifier = GlanceModifier
|
||||
.defaultWeight()
|
||||
.clickable(
|
||||
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
resId = R.drawable.ic_widget_refresh,
|
||||
@@ -224,9 +263,15 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) {
|
||||
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||
val stripeColor = pastelize(event.color, dark).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
@@ -249,14 +294,14 @@ private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) {
|
||||
.width(5.dp)
|
||||
.height(36.dp)
|
||||
.cornerRadius(3.dp)
|
||||
.background(pastelize(event.color, dark)),
|
||||
.background(stripeColor),
|
||||
) {}
|
||||
Spacer(GlanceModifier.width(10.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||
style = TextStyle(color = titleColor, fontSize = 14.sp),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event, is24Hour),
|
||||
|
||||
@@ -194,6 +194,8 @@ private fun MonthHeader(label: String) {
|
||||
),
|
||||
),
|
||||
)
|
||||
// Tap the month/year title to open the app on the month view (issue #18),
|
||||
// rooted over the default home so backing out returns there.
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
@@ -204,16 +206,17 @@ private fun MonthHeader(label: String) {
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.defaultWeight()
|
||||
.clickable(actionRunCallback<ResetMonthAction>()),
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openViewIntent(context, CalendarView.Month),
|
||||
),
|
||||
),
|
||||
)
|
||||
// The "today" button snaps the grid back to the current month in place.
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_today,
|
||||
contentDescription = context.getString(R.string.widget_today),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
|
||||
),
|
||||
),
|
||||
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
|
||||
)
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_right,
|
||||
|
||||
@@ -302,6 +302,8 @@
|
||||
</plurals>
|
||||
<string name="settings_section_event_form">Termin-Formular</string>
|
||||
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||
<string name="settings_autofocus_title">Titel bei neuem Termin fokussieren</string>
|
||||
<string name="settings_autofocus_title_hint">Beim Anlegen eines neuen Termins den Cursor direkt ins Titelfeld setzen und die Tastatur öffnen.</string>
|
||||
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
||||
<string name="settings_color_unsupported_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string>
|
||||
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||
@@ -314,7 +316,6 @@
|
||||
<string name="reminder_none">Keine</string>
|
||||
<string name="reminder_use_default">Standard-Erinnerung verwenden</string>
|
||||
<string name="reminder_custom_amount">Anzahl</string>
|
||||
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
|
||||
<string name="reminder_custom_set">Übernehmen</string>
|
||||
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>
|
||||
|
||||
@@ -282,6 +282,13 @@
|
||||
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
||||
<string name="settings_hour_lines">Hour lines</string>
|
||||
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
||||
<string name="settings_dim_completed">Dim completed events</string>
|
||||
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||
<string name="settings_past_events">Past events</string>
|
||||
<string name="settings_past_events_show">Show</string>
|
||||
<string name="settings_past_events_dim">Dim</string>
|
||||
<string name="settings_past_events_hide">Hide</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Agenda range</string>
|
||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
||||
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
||||
@@ -303,6 +310,8 @@
|
||||
</plurals>
|
||||
<string name="settings_section_event_form">New event form</string>
|
||||
<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_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>
|
||||
@@ -315,7 +324,6 @@
|
||||
<string name="reminder_none">None</string>
|
||||
<string name="reminder_use_default">Use default reminder</string>
|
||||
<string name="reminder_custom_amount">Amount</string>
|
||||
<string name="reminder_custom_with_value">Custom (%1$s)</string>
|
||||
<string name="reminder_custom_set">Set</string>
|
||||
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
|
||||
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
|
||||
|
||||
@@ -199,11 +199,12 @@ class EventWriteMapperTest {
|
||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
||||
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||
assertThat(values[CalendarContract.Events.DTEND])
|
||||
.isEqualTo(1_781_164_800_000L + 5_400_000L)
|
||||
// A single occurrence never carries its own rule.
|
||||
// The occurrence's length travels as DURATION, never DTEND — the provider
|
||||
// rejects DTEND on an exception ("Exceptions can't overwrite dtend") and
|
||||
// derives the end from DTSTART + DURATION, clearing the inherited RRULE.
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -90,6 +90,14 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.showHourLines.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.autofocusEventTitle.first()).isTrue()
|
||||
prefs.setAutofocusEventTitle(false)
|
||||
assertThat(prefs.autofocusEventTitle.first()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
@@ -175,22 +183,38 @@ class SettingsPrefsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest {
|
||||
fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||
fun `default reminder round-trips none, single, and multiple lead times`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setDefaultReminderMinutes(30)
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30)
|
||||
prefs.setDefaultReminderMinutes(null)
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||
prefs.setDefaultReminderMinutes(listOf(30))
|
||||
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
|
||||
// Several lead times persist, normalised to distinct ascending order.
|
||||
prefs.setDefaultReminderMinutes(listOf(10_080, 0, 30, 30))
|
||||
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(0, 30, 10_080).inOrder()
|
||||
prefs.setDefaultReminderMinutes(emptyList())
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage stored default reminder reads as none`(@TempDir tempDir: Path) = runTest {
|
||||
fun `legacy single-value default reminder parses as a one-element list`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
// The pre-multi-reminder format stored a bare integer string.
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "30"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage stored default reminder reads as empty`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
@@ -198,7 +222,7 @@ class SettingsPrefsTest {
|
||||
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -206,16 +230,16 @@ class SettingsPrefsTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
|
||||
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15, 10_080)))
|
||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
|
||||
prefs.perCalendarReminderOverride.first().let { map ->
|
||||
assertThat(map).containsExactly(7L, 15, 9L, null)
|
||||
assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList<Int>())
|
||||
}
|
||||
|
||||
// Inherit drops the override entirely (absent != null value).
|
||||
// Inherit drops the override entirely (absent != an empty-list value).
|
||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
|
||||
prefs.perCalendarReminderOverride.first().let { map ->
|
||||
assertThat(map).containsExactly(7L, 15)
|
||||
assertThat(map).containsExactly(7L, listOf(15, 10_080))
|
||||
assertThat(map.containsKey(9L)).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -223,48 +247,48 @@ class SettingsPrefsTest {
|
||||
@Test
|
||||
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
|
||||
prefs.setDefaultAllDayReminderMinutes(1_440)
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440)
|
||||
prefs.setDefaultAllDayReminderMinutes(null)
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
|
||||
prefs.setDefaultAllDayReminderMinutes(listOf(1_440))
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(1_440)
|
||||
prefs.setDefaultAllDayReminderMinutes(emptyList())
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
|
||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440))
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
|
||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440)
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15)))
|
||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440)))
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440))
|
||||
// Clearing the all-day override leaves the timed one untouched.
|
||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
|
||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveDefaultReminder picks the kind-matching override or global`() {
|
||||
val timed = mapOf(7L to 15, 9L to null)
|
||||
val allDay = mapOf(7L to 2_880)
|
||||
val timed = mapOf(7L to listOf(15, 10_080), 9L to emptyList<Int>())
|
||||
val allDay = mapOf(7L to listOf(2_880))
|
||||
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
|
||||
timedGlobal = 30,
|
||||
allDayGlobal = 1_440,
|
||||
timedGlobal = listOf(30),
|
||||
allDayGlobal = listOf(1_440),
|
||||
timedOverrides = timed,
|
||||
allDayOverrides = allDay,
|
||||
calendarId = calendarId,
|
||||
isAllDay = isAllDay,
|
||||
)
|
||||
// Timed: minutes override, explicit none, inherit global, no calendar.
|
||||
assertThat(resolve(7L, isAllDay = false)).isEqualTo(15)
|
||||
assertThat(resolve(9L, isAllDay = false)).isNull()
|
||||
assertThat(resolve(5L, isAllDay = false)).isEqualTo(30)
|
||||
assertThat(resolve(null, isAllDay = false)).isEqualTo(30)
|
||||
// Timed: multi-value override, explicit none, inherit global, no calendar.
|
||||
assertThat(resolve(7L, isAllDay = false)).containsExactly(15, 10_080).inOrder()
|
||||
assertThat(resolve(9L, isAllDay = false)).isEmpty()
|
||||
assertThat(resolve(5L, isAllDay = false)).containsExactly(30)
|
||||
assertThat(resolve(null, isAllDay = false)).containsExactly(30)
|
||||
// All-day: its own override wins; absent → all-day global; a timed-only
|
||||
// override (cal 9) does not bleed into all-day.
|
||||
assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880)
|
||||
assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440)
|
||||
assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440)
|
||||
assertThat(resolve(7L, isAllDay = true)).containsExactly(2_880)
|
||||
assertThat(resolve(9L, isAllDay = true)).containsExactly(1_440)
|
||||
assertThat(resolve(5L, isAllDay = true)).containsExactly(1_440)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class PostableAlertsTest {
|
||||
|
||||
private fun alert(alertId: Long, calendarId: Long) = ReminderAlert(
|
||||
alertId = alertId,
|
||||
eventId = alertId * 10,
|
||||
calendarId = calendarId,
|
||||
beginMillis = 0L,
|
||||
endMillis = 0L,
|
||||
title = "Event $alertId",
|
||||
location = null,
|
||||
isAllDay = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `keeps alerts when no calendar is disabled`() {
|
||||
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 200))
|
||||
|
||||
val postable = postableAlerts(due, disabledCalendarIds = emptySet())
|
||||
|
||||
assertThat(postable).isEqualTo(due)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drops alerts for a disabled calendar`() {
|
||||
val keep = alert(1, calendarId = 100)
|
||||
val drop = alert(2, calendarId = 200)
|
||||
|
||||
val postable = postableAlerts(listOf(keep, drop), disabledCalendarIds = setOf(200))
|
||||
|
||||
assertThat(postable).containsExactly(keep)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drops every alert when all their calendars are disabled`() {
|
||||
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
|
||||
|
||||
val postable = postableAlerts(due, disabledCalendarIds = setOf(100))
|
||||
|
||||
assertThat(postable).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps multiple alerts from the same enabled calendar`() {
|
||||
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
|
||||
|
||||
val postable = postableAlerts(due, disabledCalendarIds = setOf(999))
|
||||
|
||||
assertThat(postable).isEqualTo(due)
|
||||
}
|
||||
}
|
||||
6
fastlane/metadata/android/en-US/changelogs/21101.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/21101.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
### Fixed
|
||||
- Calendula can now be set as your default calendar app. It registers the
|
||||
calendar-app intent filters the system uses, so it appears in the chooser when
|
||||
you tap a date in a launcher or clock — and opening one takes you straight to
|
||||
that day. Android has no way for an app to make itself the default, so you pick
|
||||
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
|
||||
7
fastlane/metadata/android/en-US/changelogs/21102.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/21102.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
### Added
|
||||
- Jump straight to typing a title. When you start a new event, Calendula now puts
|
||||
the cursor in the title field and opens the keyboard right away, so you can type
|
||||
the name without an extra tap. It's on by default and only affects creating an
|
||||
event — editing never grabs focus — and a new **Focus title on new event**
|
||||
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
|
||||
suggestion ([#10]).
|
||||
19
fastlane/metadata/android/en-US/changelogs/21200.txt
Normal file
19
fastlane/metadata/android/en-US/changelogs/21200.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
### Added
|
||||
- See past events your way. Two new settings change how events that have already
|
||||
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
|
||||
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
|
||||
usual, dim them, or hide them from the list entirely. **Dim completed events**
|
||||
(Settings → Appearance) separately fades finished events in the month and week
|
||||
views. Both are off by default, an event only counts as finished once it has
|
||||
actually ended (events still in progress are never dimmed), and the lists
|
||||
update on their own as the day goes on. Thanks to @ptab for the suggestion
|
||||
([#12]).
|
||||
|
||||
### Changed
|
||||
- Readable titles on overlapping events. In the week and day views, events that
|
||||
overlap split a day into slim columns where the title used to be clipped to a
|
||||
character or two. The title now wraps across as many lines as the block can
|
||||
fit, and the time is hidden on those narrow blocks so the name can fill the
|
||||
space — so you can tell events apart without opening each one. Thanks to @ptab
|
||||
for the suggestion ([#13]).
|
||||
|
||||
Reference in New Issue
Block a user