Compare commits
22 Commits
a4e0ec3fde
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d8e3ca69e | |||
| 3b5c0be765 | |||
| 013efef29e | |||
| 5457a34282 | |||
| 82e93fda4c | |||
| 871cc34cab | |||
| 5b99da32b0 | |||
| b8a7191fbe | |||
| 6b0eb48056 | |||
| 572a4734ea | |||
| 7f8a9069c0 | |||
| 22c4eded96 | |||
| 9839f9cd38 | |||
| c0e27133b4 | |||
| 5e8cb686e9 | |||
| 62ebd48e3c | |||
| c3dbcca989 | |||
| 37452be3bd | |||
| 006cce96a9 | |||
| 88d770bebe | |||
| b6bee55fc6 | |||
| 7ab277b434 |
40
CHANGELOG.md
40
CHANGELOG.md
@@ -5,7 +5,45 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [2.8.0] — 2026-06-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Find events fast. A search button in the top bar of every calendar view opens
|
||||||
|
a search box — type a couple of letters and matching events (by title,
|
||||||
|
location or description) appear, soonest first with past events below. Tap a
|
||||||
|
result to open it. Search covers your whole calendar, not just what's on
|
||||||
|
screen, and skips calendars you've hidden. A recurring event shows its next
|
||||||
|
occurrence rather than the date the series first started.
|
||||||
|
- A current-time line in the day and week views. A thin coloured line marks the
|
||||||
|
present moment across today's column, so you can see at a glance where you are
|
||||||
|
in the day. It updates every minute and only appears when today is in view.
|
||||||
|
- Add and remove event guests. The create/edit form now has a Guests section:
|
||||||
|
add people by email or pick them from your contacts, mark each as required or
|
||||||
|
optional, and remove them. Calendula never sends invitations itself (it has no
|
||||||
|
internet access) — it only records the guests; if the event lives on a synced
|
||||||
|
account, that account may email them when it syncs, and on a local calendar no
|
||||||
|
one is notified. The form tells you which applies. Picking a guest from
|
||||||
|
contacts needs no contacts permission.
|
||||||
|
- Pick a location from your contacts. A contacts button beside the location
|
||||||
|
field drops a contact's address straight into an event — handy for a meeting
|
||||||
|
at someone's home or office. Like the guest picker, it needs no contacts
|
||||||
|
permission.
|
||||||
|
- A "New event" Quick Settings tile. Add it to your quick settings to jump
|
||||||
|
straight into the new-event form from anywhere. Settings → New event has a
|
||||||
|
one-tap button to add the tile (Android 13+); on older versions you can add it
|
||||||
|
from the quick-settings editor.
|
||||||
|
- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
|
||||||
|
reminder; snooze hides it and brings it back after a delay you pick in
|
||||||
|
Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
|
||||||
|
system won't re-post a reminder on its own, so Calendula schedules an exact
|
||||||
|
alarm to bring a snoozed one back on time.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Event details now show each guest's email beneath their name, instead of only
|
||||||
|
when no name is available.
|
||||||
|
- Crash and problem reports now open on the project's public Codeberg tracker,
|
||||||
|
where anyone can register and file an issue. Nothing is sent automatically —
|
||||||
|
you still review the report and submit it yourself in the browser.
|
||||||
|
|
||||||
## [2.7.5] — 2026-06-21
|
## [2.7.5] — 2026-06-21
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 20705
|
versionCode = 20800
|
||||||
versionName = "2.7.5"
|
versionName = "2.8.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Re-fire a snoozed reminder at an exact time (the calendar provider won't —
|
||||||
|
its alert is already fired). USE_EXACT_ALARM is auto-granted to calendar
|
||||||
|
apps on API 33+; SCHEDULE_EXACT_ALARM covers API 31–32 (user-revocable,
|
||||||
|
with an inexact fallback if withheld). F-Droid-clean: no Play allowlisting.
|
||||||
|
-->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.SCHEDULE_EXACT_ALARM"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
|
|
||||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||||
returns null and the calendar manager's per-account "manage" button can't
|
returns null and the calendar manager's per-account "manage" button can't
|
||||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||||
@@ -77,6 +88,20 @@
|
|||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:launchMode="singleTask" />
|
android:launchMode="singleTask" />
|
||||||
|
|
||||||
|
<!-- Quick Settings tile: a one-tap "New event" shortcut in the QS panel.
|
||||||
|
Exported with BIND_QUICK_SETTINGS_TILE so only the system QS host can
|
||||||
|
bind it; the action mirrors the launcher "New event" shortcut. -->
|
||||||
|
<service
|
||||||
|
android:name=".qs.NewEventTileService"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@drawable/ic_qs_new_event"
|
||||||
|
android:label="@string/qs_tile_new_event_label"
|
||||||
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||||
no notification itself — a calendar app must (v1.4, Etar model).
|
no notification itself — a calendar app must (v1.4, Etar model).
|
||||||
Exported: the broadcast arrives from the provider's process. -->
|
Exported: the broadcast arrives from the provider's process. -->
|
||||||
@@ -91,6 +116,13 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Snooze / dismiss actions on a reminder notification, plus the snooze
|
||||||
|
re-show alarm. Not exported: only our own notification buttons and
|
||||||
|
AlarmManager PendingIntents target it. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".data.reminders.ReminderActionReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.agenda.AgendaWidgetReceiver"
|
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ interface CalendarDataSource {
|
|||||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||||
fun eventDetail(eventId: Long): EventDetail?
|
fun eventDetail(eventId: Long): EventDetail?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master/one-off events whose title, description or location contains
|
||||||
|
* [query] (case-insensitive), across all calendars, newest first. Reads the
|
||||||
|
* Events table directly so the search is unbounded in time; exception rows
|
||||||
|
* are excluded (see [SearchProjection]). [query] is assumed non-blank.
|
||||||
|
*/
|
||||||
|
fun searchEvents(query: String): List<EventInstance>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette the calendar's account publishes
|
* The event-colour palette the calendar's account publishes
|
||||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
||||||
@@ -298,6 +306,89 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun searchEvents(query: String): List<EventInstance> {
|
||||||
|
ensureObserversRegistered()
|
||||||
|
val trimmed = query.trim()
|
||||||
|
if (trimmed.isEmpty()) return emptyList()
|
||||||
|
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches
|
||||||
|
// itself instead of acting as a wildcard.
|
||||||
|
val escaped = trimmed
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("%", "\\%")
|
||||||
|
.replace("_", "\\_")
|
||||||
|
val like = "%$escaped%"
|
||||||
|
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||||
|
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||||
|
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
||||||
|
val selection = "($match) AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
|
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||||
|
return resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
SearchProjection.COLUMNS,
|
||||||
|
selection,
|
||||||
|
arrayOf(like, like, like),
|
||||||
|
CalendarContract.Events.DTSTART + " DESC",
|
||||||
|
)?.use { c ->
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
val out = ArrayList<EventInstance>(c.count)
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val base = reader.toSearchResult() ?: continue
|
||||||
|
// A recurring master's DTSTART is the series start; show its
|
||||||
|
// nearest occurrence instead so the date is the one the user
|
||||||
|
// actually cares about (and sorting reflects it).
|
||||||
|
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||||
|
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
|
||||||
|
out += if (recurring) {
|
||||||
|
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
||||||
|
base.copy(
|
||||||
|
start = begin.toKotlinInstantFromEpochMillis(),
|
||||||
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
|
)
|
||||||
|
} ?: base
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
||||||
|
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
||||||
|
* within the same window behind. Null when neither exists (e.g. a series
|
||||||
|
* that only starts further out than the window) — the caller then keeps the
|
||||||
|
* series-start DTSTART. Returns (begin, end) epoch millis.
|
||||||
|
*/
|
||||||
|
private fun nearestOccurrenceMillis(eventId: Long): Pair<Long, Long>? {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return occurrenceInWindow(eventId, now, now + OCCURRENCE_WINDOW_MILLIS, soonestFirst = true)
|
||||||
|
?: occurrenceInWindow(eventId, now - OCCURRENCE_WINDOW_MILLIS, now, soonestFirst = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun occurrenceInWindow(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
endMillis: Long,
|
||||||
|
soonestFirst: Boolean,
|
||||||
|
): Pair<Long, Long>? {
|
||||||
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
|
ContentUris.appendId(this, beginMillis)
|
||||||
|
ContentUris.appendId(this, endMillis)
|
||||||
|
}.build()
|
||||||
|
val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
|
||||||
|
return resolver.query(
|
||||||
|
uri,
|
||||||
|
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
|
||||||
|
"${CalendarContract.Instances.EVENT_ID} = ?",
|
||||||
|
arrayOf(eventId.toString()),
|
||||||
|
order,
|
||||||
|
)?.use { c ->
|
||||||
|
if (!c.moveToFirst()) null else c.getLong(0) to c.getLong(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun eventDetail(eventId: Long): EventDetail? {
|
override fun eventDetail(eventId: Long): EventDetail? {
|
||||||
val attendees = queryAttendees(eventId)
|
val attendees = queryAttendees(eventId)
|
||||||
val reminders = queryReminders(eventId)
|
val reminders = queryReminders(eventId)
|
||||||
@@ -955,5 +1046,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
* together (by account) in the filter sheet and calendar manager.
|
* together (by account) in the filter sheet and calendar manager.
|
||||||
*/
|
*/
|
||||||
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far ahead/behind a search looks for a recurring event's nearest
|
||||||
|
* occurrence (~2 years). Wide enough for everyday series; a series that
|
||||||
|
* next fires beyond it falls back to its series-start date.
|
||||||
|
*/
|
||||||
|
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ interface CalendarRepository {
|
|||||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events whose title, description or location contains [query], with hidden
|
||||||
|
* calendars removed and newest first. Empty when [query] is blank. Searches
|
||||||
|
* the whole history/future (see [CalendarDataSource.searchEvents]).
|
||||||
|
*/
|
||||||
|
suspend fun searchEvents(query: String): List<EventInstance>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette a calendar's account publishes; empty when it
|
* The event-colour palette a calendar's account publishes; empty when it
|
||||||
* exposes none (see [CalendarDataSource.eventColorPalette]).
|
* exposes none (see [CalendarDataSource.eventColorPalette]).
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||||
|
if (query.isBlank()) return@withContext emptyList()
|
||||||
|
val hidden = prefs.hiddenCalendarIds.first()
|
||||||
|
dataSource.searchEvents(query)
|
||||||
|
.let { if (hidden.isEmpty()) it else it.filterNot { e -> e.calendarId in hidden } }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
withContext(io) { dataSource.eventColorPalette(calendarId) }
|
withContext(io) { dataSource.eventColorPalette(calendarId) }
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,44 @@ internal object EventExportProjection {
|
|||||||
const val IDX_CALENDAR_ID = 13
|
const val IDX_CALENDAR_ID = 13
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master/one-off Events rows matched by a full-text search. Like
|
||||||
|
* [EventExportProjection] it reads the Events table directly (so the search is
|
||||||
|
* unbounded in time), carrying DURATION for recurring rows that have no DTEND.
|
||||||
|
* Colour folds the calendar fallback like [InstanceProjection].
|
||||||
|
*/
|
||||||
|
internal object SearchProjection {
|
||||||
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
|
CalendarContract.Events._ID,
|
||||||
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
CalendarContract.Events.TITLE,
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.DTEND,
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events.EVENT_COLOR,
|
||||||
|
CalendarContract.Events.CALENDAR_COLOR,
|
||||||
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
|
// Recurrence markers: a non-empty RRULE or RDATE means the result should
|
||||||
|
// display its nearest occurrence, not the series-start DTSTART.
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
CalendarContract.Events.RDATE,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val IDX_ID = 0
|
||||||
|
const val IDX_CALENDAR_ID = 1
|
||||||
|
const val IDX_TITLE = 2
|
||||||
|
const val IDX_DTSTART = 3
|
||||||
|
const val IDX_DTEND = 4
|
||||||
|
const val IDX_DURATION = 5
|
||||||
|
const val IDX_ALL_DAY = 6
|
||||||
|
const val IDX_EVENT_COLOR = 7
|
||||||
|
const val IDX_CALENDAR_COLOR = 8
|
||||||
|
const val IDX_LOCATION = 9
|
||||||
|
const val IDX_RRULE = 10
|
||||||
|
const val IDX_RDATE = 11
|
||||||
|
}
|
||||||
|
|
||||||
internal object AttendeeProjection {
|
internal object AttendeeProjection {
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
|
||||||
|
* Instances query this reads the series master, so there is no instance id (the
|
||||||
|
* event id stands in as the list key) and recurring rows carry DURATION instead
|
||||||
|
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
||||||
|
*/
|
||||||
|
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||||
|
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
||||||
|
if (dtStart < 0L) return null
|
||||||
|
val end = when {
|
||||||
|
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
||||||
|
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||||
|
}.coerceAtLeast(dtStart)
|
||||||
|
|
||||||
|
val rawTitle = getString(SearchProjection.IDX_TITLE)
|
||||||
|
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||||
|
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
||||||
|
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||||
|
} else {
|
||||||
|
getInt(SearchProjection.IDX_EVENT_COLOR)
|
||||||
|
}
|
||||||
|
val eventId = getLong(SearchProjection.IDX_ID)
|
||||||
|
|
||||||
|
return EventInstance(
|
||||||
|
instanceId = eventId,
|
||||||
|
eventId = eventId,
|
||||||
|
calendarId = getLong(SearchProjection.IDX_CALENDAR_ID),
|
||||||
|
title = title,
|
||||||
|
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||||
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
|
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
||||||
|
color = color,
|
||||||
|
location = getString(SearchProjection.IDX_LOCATION),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -177,6 +177,20 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
|
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the notification "Snooze" action defers a reminder, in minutes.
|
||||||
|
* Tapping Snooze cancels the notification and re-posts it after this delay
|
||||||
|
* via an exact alarm (the calendar provider won't re-fire). Default 10 min;
|
||||||
|
* clamped to at least 1.
|
||||||
|
*/
|
||||||
|
val snoozeMinutes: Flow<Int> = store.data.map { prefs ->
|
||||||
|
(prefs[SNOOZE_MINUTES_KEY] ?: DEFAULT_SNOOZE_MINUTES).coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSnoozeMinutes(minutes: Int) {
|
||||||
|
store.edit { it[SNOOZE_MINUTES_KEY] = minutes.coerceAtLeast(1) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
||||||
* keyed by calendar id. A calendar **present** in the map overrides the global
|
* keyed by calendar id. A calendar **present** in the map overrides the global
|
||||||
@@ -243,6 +257,9 @@ class SettingsPrefs @Inject constructor(
|
|||||||
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
|
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
|
||||||
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||||
private const val MINUTES_PER_DAY = 1_440
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
|
||||||
|
/** Default snooze delay for the notification "Snooze" action. */
|
||||||
|
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||||
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
||||||
stringPreferencesKey("per_calendar_reminder_override")
|
stringPreferencesKey("per_calendar_reminder_override")
|
||||||
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus
|
||||||
|
* the internal re-show when a snooze elapses. All three are app-internal
|
||||||
|
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||||
|
* alarm), so the receiver is not exported.
|
||||||
|
*
|
||||||
|
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
||||||
|
* already fired, so nothing re-posts it.
|
||||||
|
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||||
|
* it after the user's snooze delay.
|
||||||
|
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
||||||
|
* or dismiss it again.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ReminderActionReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var notifier: ReminderNotifier
|
||||||
|
@Inject lateinit var scheduler: ReminderSnoozeScheduler
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val alert = alertFrom(intent) ?: return
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_DISMISS -> notifier.cancel(alert)
|
||||||
|
|
||||||
|
ACTION_SNOOZE -> {
|
||||||
|
// Cancel now so the notification doesn't linger until the alarm;
|
||||||
|
// the snooze delay read is the only async work.
|
||||||
|
notifier.cancel(alert)
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
val minutes = settingsPrefs.snoozeMinutes.first()
|
||||||
|
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
|
||||||
|
scheduler.schedule(alert, triggerAt)
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTION_SHOW -> {
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
||||||
|
notifier.post(alert)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_SNOOZE = "de.jeanlucmakiola.calendula.reminders.SNOOZE"
|
||||||
|
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||||
|
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
||||||
|
|
||||||
|
private const val EXTRA_ALERT_ID = "alert_id"
|
||||||
|
private const val EXTRA_EVENT_ID = "event_id"
|
||||||
|
private const val EXTRA_BEGIN = "begin"
|
||||||
|
private const val EXTRA_END = "end"
|
||||||
|
private const val EXTRA_TITLE = "title"
|
||||||
|
private const val EXTRA_LOCATION = "location"
|
||||||
|
private const val EXTRA_ALL_DAY = "all_day"
|
||||||
|
|
||||||
|
/** An explicit intent to this receiver carrying [alert] as extras. */
|
||||||
|
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||||
|
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||||
|
this.action = action
|
||||||
|
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||||
|
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||||
|
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||||
|
putExtra(EXTRA_END, alert.endMillis)
|
||||||
|
putExtra(EXTRA_TITLE, alert.title)
|
||||||
|
putExtra(EXTRA_LOCATION, alert.location)
|
||||||
|
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stable request code per (alert, action) so the three PendingIntents
|
||||||
|
* of one notification stay distinct and don't clobber each other.
|
||||||
|
*/
|
||||||
|
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||||
|
val actionOffset = when (action) {
|
||||||
|
ACTION_SNOOZE -> 1
|
||||||
|
ACTION_DISMISS -> 2
|
||||||
|
ACTION_SHOW -> 3
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
return alert.alertId.toInt() * 8 + actionOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||||
|
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
||||||
|
return ReminderAlert(
|
||||||
|
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||||
|
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||||
|
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||||
|
endMillis = intent.getLongExtra(EXTRA_END, 0L),
|
||||||
|
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
||||||
|
location = intent.getStringExtra(EXTRA_LOCATION),
|
||||||
|
isAllDay = intent.getBooleanExtra(EXTRA_ALL_DAY, false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,16 @@ class ReminderNotifier @Inject constructor(
|
|||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setContentIntent(detailIntent(alert))
|
.setContentIntent(detailIntent(alert))
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_notification_snooze,
|
||||||
|
context.getString(R.string.reminder_action_snooze),
|
||||||
|
actionIntent(alert, ReminderActionReceiver.ACTION_SNOOZE),
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_notification_dismiss,
|
||||||
|
context.getString(R.string.reminder_action_dismiss),
|
||||||
|
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
|
||||||
|
)
|
||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
NotificationManagerCompat.from(context)
|
NotificationManagerCompat.from(context)
|
||||||
@@ -71,6 +81,19 @@ class ReminderNotifier @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||||
|
fun cancel(alert: ReminderAlert) {
|
||||||
|
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
ReminderActionReceiver.requestCode(alert, action),
|
||||||
|
ReminderActionReceiver.intent(context, action, alert),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
/* requestCode = */ alert.alertId.toInt(),
|
/* requestCode = */ alert.alertId.toInt(),
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
||||||
|
*
|
||||||
|
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
||||||
|
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
||||||
|
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
||||||
|
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
||||||
|
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
||||||
|
* exact-alarm capability (API 31–32 where the user revoked it).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderSnoozeScheduler @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun schedule(alert: ReminderAlert, triggerAtMillis: Long) {
|
||||||
|
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||||
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW),
|
||||||
|
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
if (canScheduleExact(alarmManager)) {
|
||||||
|
alarmManager.setExactAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Exact alarms revoked (API 31–32): an inexact wake is the honest
|
||||||
|
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
|
||||||
|
alarmManager.setAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True on API < 31 (no restriction), and on 31+ when the exact-alarm
|
||||||
|
* capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+
|
||||||
|
* (Calendula is a calendar app), user-revocable on 31–32.
|
||||||
|
*/
|
||||||
|
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.qs
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.os.Build
|
||||||
|
import android.service.quicksettings.TileService
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick Settings tile: tapping it opens the create-event form on today — the
|
||||||
|
* same action as the launcher "New event" shortcut and the agenda widget's "+".
|
||||||
|
* A stateless action tile, so there is no on/off state to keep in sync.
|
||||||
|
*/
|
||||||
|
class NewEventTileService : TileService() {
|
||||||
|
|
||||||
|
// The pre-34 branch intentionally uses the deprecated Intent overload: it is
|
||||||
|
// the only form available below UpsideDownCake, and is reached only there.
|
||||||
|
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
|
||||||
|
override fun onClick() {
|
||||||
|
super.onClick()
|
||||||
|
val today = Clock.System.now()
|
||||||
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
val intent = MainActivity.openCreateIntent(this, today)
|
||||||
|
// Launch only once the device is unlocked: creating an event behind the
|
||||||
|
// keyguard makes no sense, and the shade can't start an activity over a
|
||||||
|
// locked screen anyway.
|
||||||
|
unlockAndRun {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
val pending = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
startActivityAndCollapse(pending)
|
||||||
|
} else {
|
||||||
|
startActivityAndCollapse(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
|||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -97,6 +98,12 @@ fun CalendarHost(
|
|||||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||||
val onOpenSettings = { showSettings = true }
|
val onOpenSettings = { showSettings = true }
|
||||||
|
|
||||||
|
// Full-text search — its own overlay, opened from each calendar screen's
|
||||||
|
// top bar. Sits below the detail/edit overlays so tapping a result reveals
|
||||||
|
// the detail on top and backing out returns to the results.
|
||||||
|
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val onOpenSearch = { showSearch = true }
|
||||||
|
|
||||||
// Calendar manager (reached from Settings) — its own overlay so it slides
|
// Calendar manager (reached from Settings) — its own overlay so it slides
|
||||||
// over Settings and survives view switches.
|
// over Settings and survives view switches.
|
||||||
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -137,16 +144,37 @@ fun CalendarHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close every overlay that can sit over the calendar, so an externally
|
||||||
|
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||||
|
// top instead of underneath whatever the user had open.
|
||||||
|
fun dismissCoveringOverlays() {
|
||||||
|
showSettings = false
|
||||||
|
showCalendars = false
|
||||||
|
detailKey = null
|
||||||
|
editKey = null
|
||||||
|
importUri = null
|
||||||
|
importForm = null
|
||||||
|
}
|
||||||
|
|
||||||
// A home-screen widget launch asks to open a date (→ day view) or start a
|
// A home-screen widget launch asks to open a date (→ day view) or start a
|
||||||
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
||||||
LaunchedEffect(widgetNavRequest) {
|
LaunchedEffect(widgetNavRequest) {
|
||||||
when (val req = widgetNavRequest) {
|
when (val req = widgetNavRequest) {
|
||||||
is WidgetNavRequest.OpenDate -> {
|
is WidgetNavRequest.OpenDate -> {
|
||||||
|
// Reveal the day view: drop any overlay that would cover it, so
|
||||||
|
// an external date-open doesn't land under an open Settings/form.
|
||||||
|
dismissCoveringOverlays()
|
||||||
|
createDateIso = null
|
||||||
pendingDayIso = req.dateIso
|
pendingDayIso = req.dateIso
|
||||||
view = CalendarView.Day
|
view = CalendarView.Day
|
||||||
onWidgetNavConsumed()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
is WidgetNavRequest.Create -> {
|
is WidgetNavRequest.Create -> {
|
||||||
|
// External "new event" entries (QS tile / launcher shortcut /
|
||||||
|
// widget) must land on top of whatever is open — the form overlay
|
||||||
|
// sits below Settings/calendars in the Box, so without this it
|
||||||
|
// would open hidden underneath them.
|
||||||
|
dismissCoveringOverlays()
|
||||||
val iso = req.dateIso ?: Clock.System.now()
|
val iso = req.dateIso ?: Clock.System.now()
|
||||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
|
||||||
heldCreateIso = iso
|
heldCreateIso = iso
|
||||||
@@ -168,6 +196,7 @@ fun CalendarHost(
|
|||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
@@ -175,6 +204,7 @@ fun CalendarHost(
|
|||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
)
|
)
|
||||||
@@ -183,6 +213,7 @@ fun CalendarHost(
|
|||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
@@ -190,10 +221,24 @@ fun CalendarHost(
|
|||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||||
|
// detail screen draws on top, and closing it returns to the results.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showSearch,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
SearchScreen(
|
||||||
|
onBack = { showSearch = false },
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Prefer the live key; fall back to the held one only while sliding out.
|
// Prefer the live key; fall back to the held one only while sliding out.
|
||||||
val activeKey = detailKey ?: heldKey
|
val activeKey = detailKey ?: heldKey
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.EventAvailable
|
import androidx.compose.material.icons.filled.EventAvailable
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -75,6 +76,7 @@ fun AgendaScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AgendaViewModel = hiltViewModel(),
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
@@ -119,6 +121,7 @@ fun AgendaScreen(
|
|||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -274,6 +277,7 @@ private fun AgendaTopBar(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -292,6 +296,12 @@ private fun AgendaTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSearch) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
private val DotSize = 8.dp
|
||||||
|
private val LineThickness = 2.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current wall-clock instant that updates once a minute, re-aligning to each
|
||||||
|
* minute boundary (rather than ticking a fixed 60 s) so it never drifts toward
|
||||||
|
* the middle of a minute. Drives the "now" line in the day/week grids.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberCurrentMinute(): State<Instant> {
|
||||||
|
val instant = remember { mutableStateOf(Clock.System.now()) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
val now = Clock.System.now()
|
||||||
|
instant.value = now
|
||||||
|
delay(60_000L - now.toEpochMilliseconds() % 60_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instant
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A thin "current time" indicator — a leading dot plus a line — drawn across a
|
||||||
|
* day column. Positioned on the same [hourHeight] scale the event blocks use so
|
||||||
|
* it lines up with the grid, and refreshed each minute. Renders nothing unless
|
||||||
|
* the wall clock is on [date]; callers mount it only for the column showing
|
||||||
|
* today, so the per-minute tick runs on a single column.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NowLine(
|
||||||
|
date: LocalDate,
|
||||||
|
hourHeight: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val now by rememberCurrentMinute()
|
||||||
|
val local = now.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
|
if (local.date != date) return
|
||||||
|
|
||||||
|
val minutes = local.hour * 60 + local.minute
|
||||||
|
val top = hourHeight * (minutes / 60f)
|
||||||
|
val color = MaterialTheme.colorScheme.primary
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(DotSize)
|
||||||
|
.offset(y = top - DotSize / 2),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(LineThickness)
|
||||||
|
.background(color),
|
||||||
|
)
|
||||||
|
// The dot anchors the line to the gutter edge, mirroring the standard
|
||||||
|
// calendar "now" marker; drawn after the line so it sits on top.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(DotSize)
|
||||||
|
.background(color, CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
@@ -71,6 +72,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
@@ -105,6 +107,7 @@ fun DayScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
@@ -188,6 +191,7 @@ fun DayScreen(
|
|||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -340,6 +344,7 @@ private fun DayTopBar(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -358,6 +363,12 @@ private fun DayTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSearch) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
@@ -496,6 +507,7 @@ private fun Timeline(
|
|||||||
blocks = state.timed,
|
blocks = state.timed,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
date = state.date,
|
date = state.date,
|
||||||
|
today = state.today,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -512,6 +524,7 @@ private fun DayColumnCard(
|
|||||||
blocks: List<TimedBlock>,
|
blocks: List<TimedBlock>,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -556,6 +569,10 @@ private fun DayColumnCard(
|
|||||||
.padding(horizontal = 1.dp),
|
.padding(horizontal = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Current-time line, on top of the events, only on today's column.
|
||||||
|
if (date == today) {
|
||||||
|
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,6 +461,21 @@ private fun EventEditContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pick a postal address from contacts: the same one-shot picker, scoped to
|
||||||
|
// address rows, dropping the chosen contact's formatted address into the
|
||||||
|
// location field. Same no-permission guarantee as the guest picker above.
|
||||||
|
val pickContactAddress = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
|
result.data?.data?.let { uri ->
|
||||||
|
readContactAddress(context, uri)?.let { address ->
|
||||||
|
viewModel.setLocation(address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
||||||
// The accent ties the form to the detail screen's design language: the
|
// The accent ties the form to the detail screen's design language: the
|
||||||
// bar under the title takes the target calendar's colour.
|
// bar under the title takes the target calendar's colour.
|
||||||
@@ -592,11 +607,39 @@ private fun EventEditContent(
|
|||||||
icon = Icons.Default.Place,
|
icon = Icons.Default.Place,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||||
) {
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
InlineField(
|
InlineField(
|
||||||
value = form.location,
|
value = form.location,
|
||||||
onValueChange = viewModel::setLocation,
|
onValueChange = viewModel::setLocation,
|
||||||
placeholder = stringResource(R.string.event_detail_location),
|
placeholder = stringResource(R.string.event_detail_location),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
)
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
runCatching {
|
||||||
|
pickContactAddress.launch(
|
||||||
|
Intent(
|
||||||
|
Intent.ACTION_PICK,
|
||||||
|
ContactsContract.CommonDataKinds.StructuredPostal
|
||||||
|
.CONTENT_URI,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Contacts,
|
||||||
|
contentDescription = stringResource(
|
||||||
|
R.string.event_edit_location_from_contacts,
|
||||||
|
),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1578,6 +1621,27 @@ private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>?
|
|||||||
email.takeIf { it.isNotEmpty() }?.let { it to name }
|
email.takeIf { it.isNotEmpty() }?.let { it to name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the formatted postal address from a contact-pick result URI. No
|
||||||
|
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
|
||||||
|
* The provider's formatted address is multi-line; collapse it to one line so
|
||||||
|
* it sits cleanly in the single-line location field.
|
||||||
|
*/
|
||||||
|
private fun readContactAddress(context: Context, uri: Uri): String? =
|
||||||
|
context.contentResolver.query(
|
||||||
|
uri,
|
||||||
|
arrayOf(ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS),
|
||||||
|
null, null, null,
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) return@use null
|
||||||
|
cursor.getString(0).orEmpty()
|
||||||
|
.lines()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.joinToString(", ")
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun fieldLabel(field: EventFormField): Int = when (field) {
|
private fun fieldLabel(field: EventFormField): Int = when (field) {
|
||||||
EventFormField.Location -> R.string.event_detail_location
|
EventFormField.Location -> R.string.event_detail_location
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -86,6 +87,7 @@ fun MonthScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MonthViewModel = hiltViewModel(),
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
@@ -161,6 +163,7 @@ fun MonthScreen(
|
|||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -261,6 +264,7 @@ private fun MonthTopBar(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -279,6 +283,12 @@ private fun MonthTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSearch) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.search
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.SearchOff
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
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.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
import java.time.Instant as JavaInstant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-text event search (top-bar entry). Type a query → matching events
|
||||||
|
* (title / location / description) across the whole calendar, newest-relevant
|
||||||
|
* first; tap a result to open its detail. A full-screen overlay hosted by
|
||||||
|
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SearchScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: SearchViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
|
// Each fresh open starts blank and straight into typing. The ViewModel is
|
||||||
|
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
|
||||||
|
// resets a previous search. Peeking a result doesn't re-run this (the screen
|
||||||
|
// stays composed under the detail), so backing out keeps the query.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.setQuery("")
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
keyboard?.show()
|
||||||
|
}
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
InlineTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = viewModel::setQuery,
|
||||||
|
placeholder = stringResource(R.string.search_hint),
|
||||||
|
imeAction = ImeAction.Search,
|
||||||
|
onImeAction = { keyboard?.hide() },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.search_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { viewModel.setQuery("") }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.search_clear),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
// imePadding shrinks the content by the keyboard, so the centered
|
||||||
|
// idle/empty message re-centres in the space above it (and the results
|
||||||
|
// list lifts clear of the keyboard too).
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.imePadding(),
|
||||||
|
) {
|
||||||
|
when (val s = state) {
|
||||||
|
SearchUiState.Idle -> SearchMessage(
|
||||||
|
icon = null,
|
||||||
|
text = stringResource(R.string.search_idle_hint),
|
||||||
|
)
|
||||||
|
is SearchUiState.Empty -> SearchMessage(
|
||||||
|
icon = Icons.Default.SearchOff,
|
||||||
|
text = stringResource(R.string.search_empty, s.query),
|
||||||
|
)
|
||||||
|
is SearchUiState.Results -> SearchResults(
|
||||||
|
events = s.events,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchResults(
|
||||||
|
events: List<EventInstance>,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(
|
||||||
|
items = events,
|
||||||
|
key = { _, event -> event.eventId },
|
||||||
|
) { index, event ->
|
||||||
|
SearchResultRow(
|
||||||
|
event = event,
|
||||||
|
position = positionOf(index, events.size),
|
||||||
|
onClick = { onEventClick(event) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchResultRow(
|
||||||
|
event: EventInstance,
|
||||||
|
position: Position,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
GroupedRow(
|
||||||
|
title = event.title,
|
||||||
|
summary = searchSummary(event),
|
||||||
|
position = position,
|
||||||
|
minHeight = 64.dp,
|
||||||
|
leading = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 6.dp, height = 36.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(pastelize(event.color, dark)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
|
||||||
|
@Composable
|
||||||
|
private fun searchSummary(event: EventInstance): String {
|
||||||
|
val locale = currentLocale()
|
||||||
|
val zone = remember { ZoneId.systemDefault() }
|
||||||
|
val start = remember(event.start, zone) {
|
||||||
|
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
||||||
|
}
|
||||||
|
val dateText = remember(locale) {
|
||||||
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
|
}.format(start)
|
||||||
|
val timeText = if (event.isAllDay) {
|
||||||
|
stringResource(R.string.event_detail_all_day)
|
||||||
|
} else {
|
||||||
|
remember(locale) {
|
||||||
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||||
|
}.format(start)
|
||||||
|
}
|
||||||
|
val base = "$dateText · $timeText"
|
||||||
|
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchMessage(
|
||||||
|
icon: ImageVector?,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.search
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
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.domain.EventInstance
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** Shortest query that triggers a search; one character matches almost everything. */
|
||||||
|
private const val MIN_QUERY_LENGTH = 2
|
||||||
|
|
||||||
|
sealed interface SearchUiState {
|
||||||
|
/** No query yet, or one too short to search — show the prompt. */
|
||||||
|
data object Idle : SearchUiState
|
||||||
|
|
||||||
|
/** A query ran but matched nothing. */
|
||||||
|
data class Empty(val query: String) : SearchUiState
|
||||||
|
|
||||||
|
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
|
||||||
|
data class Results(val events: List<EventInstance>) : SearchUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class SearchViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _query = MutableStateFlow("")
|
||||||
|
val query: StateFlow<String> = _query.asStateFlow()
|
||||||
|
|
||||||
|
val state: StateFlow<SearchUiState> = _query
|
||||||
|
.debounce(250L)
|
||||||
|
.map { it.trim() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.mapLatest { q ->
|
||||||
|
if (q.length < MIN_QUERY_LENGTH) {
|
||||||
|
SearchUiState.Idle
|
||||||
|
} else {
|
||||||
|
val results = repository.searchEvents(q)
|
||||||
|
if (results.isEmpty()) SearchUiState.Empty(q)
|
||||||
|
else SearchUiState.Results(sortNearestFirst(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.catch { emit(SearchUiState.Idle) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
||||||
|
|
||||||
|
fun setQuery(value: String) {
|
||||||
|
_query.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soonest upcoming (and ongoing) first, then the most recent past. */
|
||||||
|
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
|
||||||
|
val now = Clock.System.now()
|
||||||
|
val (upcoming, past) = events.partition { it.end >= now }
|
||||||
|
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.StatusBarManager
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import android.text.format.DateFormat
|
import android.text.format.DateFormat
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -63,6 +67,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -79,6 +84,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
@@ -513,7 +519,37 @@ private fun EventFormScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// One-tap add of the "New event" Quick Settings tile. The system prompt
|
||||||
|
// is API 33+; on older versions the tile is still addable manually from
|
||||||
|
// the QS editor, so the row simply doesn't appear there.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
val context = LocalContext.current
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_qs_tile),
|
||||||
|
summary = stringResource(R.string.settings_qs_tile_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { requestAddQsTile(context) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the system to add the "New event" Quick Settings tile (API 33+). The OS
|
||||||
|
* shows its own confirmation dialog and handles the already-added case, so no
|
||||||
|
* result handling is needed here.
|
||||||
|
*/
|
||||||
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
|
private fun requestAddQsTile(context: Context) {
|
||||||
|
val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return
|
||||||
|
statusBar.requestAddTileService(
|
||||||
|
ComponentName(context, NewEventTileService::class.java),
|
||||||
|
context.getString(R.string.qs_tile_new_event_label),
|
||||||
|
Icon.createWithResource(context, R.drawable.ic_qs_new_event),
|
||||||
|
context.mainExecutor,
|
||||||
|
) { /* result code unused — the system surfaces its own feedback */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -546,6 +582,7 @@ private fun NotificationsScreen(
|
|||||||
var showDefaultReminder by remember { mutableStateOf(false) }
|
var showDefaultReminder by remember { mutableStateOf(false) }
|
||||||
var showAllDayReminder by remember { mutableStateOf(false) }
|
var showAllDayReminder by remember { mutableStateOf(false) }
|
||||||
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||||
|
var showSnooze by remember { mutableStateOf(false) }
|
||||||
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||||
|
|
||||||
@@ -666,8 +703,27 @@ private fun NotificationsScreen(
|
|||||||
},
|
},
|
||||||
onClick = { openBatteryOptimizationSettings(context) },
|
onClick = { openBatteryOptimizationSettings(context) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Snooze: how long the notification's "Snooze" action defers a reminder.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
|
summary = snoozeDurationLabel(state.snoozeMinutes),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { showSnooze = true },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSnooze) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
|
options = SNOOZE_PRESETS,
|
||||||
|
selected = state.snoozeMinutes,
|
||||||
|
label = { snoozeDurationLabel(it) },
|
||||||
|
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||||
|
onDismiss = { showSnooze = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showDefaultReminder) {
|
if (showDefaultReminder) {
|
||||||
ReminderDefaultPicker(
|
ReminderDefaultPicker(
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
@@ -792,6 +848,18 @@ private fun openBatteryOptimizationSettings(context: Context) {
|
|||||||
*/
|
*/
|
||||||
private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
|
private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
|
||||||
|
|
||||||
|
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||||
|
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||||
|
|
||||||
|
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
|
||||||
|
@Composable
|
||||||
|
private fun snoozeDurationLabel(minutes: Int): String =
|
||||||
|
if (minutes % 60 == 0) {
|
||||||
|
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
|
||||||
|
} else {
|
||||||
|
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
/** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */
|
/** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */
|
||||||
private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
||||||
val time = Calendar.getInstance().apply {
|
val time = Calendar.getInstance().apply {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ data class SettingsUiState(
|
|||||||
val defaultAllDayReminderMinutes: Int? = null,
|
val defaultAllDayReminderMinutes: Int? = null,
|
||||||
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
||||||
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
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
|
* 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 (null value = no
|
||||||
|
|||||||
@@ -59,8 +59,11 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.defaultReminderMinutes,
|
prefs.defaultReminderMinutes,
|
||||||
prefs.defaultAllDayReminderMinutes,
|
prefs.defaultAllDayReminderMinutes,
|
||||||
prefs.allDayReminderTimeMinutes,
|
prefs.allDayReminderTimeMinutes,
|
||||||
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime ->
|
prefs.snoozeMinutes,
|
||||||
ReminderDefaults(allowColor, defaultReminder, allDayReminder, allDayReminderTime)
|
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze ->
|
||||||
|
ReminderDefaults(
|
||||||
|
allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
prefs.perCalendarReminderOverride,
|
prefs.perCalendarReminderOverride,
|
||||||
@@ -75,6 +78,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
allDayReminderTimeMinutes = defaults.allDayReminderTime,
|
allDayReminderTimeMinutes = defaults.allDayReminderTime,
|
||||||
|
snoozeMinutes = defaults.snoozeMinutes,
|
||||||
perCalendarReminderOverride = overrides.timed,
|
perCalendarReminderOverride = overrides.timed,
|
||||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||||
writableCalendars = overrides.calendars,
|
writableCalendars = overrides.calendars,
|
||||||
@@ -90,6 +94,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val defaultReminder: Int?,
|
val defaultReminder: Int?,
|
||||||
val allDayReminder: Int?,
|
val allDayReminder: Int?,
|
||||||
val allDayReminderTime: Int,
|
val allDayReminderTime: Int,
|
||||||
|
val snoozeMinutes: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ReminderOverrides(
|
private data class ReminderOverrides(
|
||||||
@@ -130,6 +135,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setSnoozeMinutes(minutes: Int) {
|
||||||
|
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
@@ -76,6 +77,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
@@ -113,6 +115,7 @@ fun WeekScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
@@ -193,6 +196,7 @@ fun WeekScreen(
|
|||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next()) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -351,6 +355,7 @@ private fun WeekTopBar(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -369,6 +374,12 @@ private fun WeekTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSearch) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
@@ -610,6 +621,7 @@ private fun Timeline(
|
|||||||
blocks = state.timedByDay[day].orEmpty(),
|
blocks = state.timedByDay[day].orEmpty(),
|
||||||
dark = dark,
|
dark = dark,
|
||||||
date = day,
|
date = day,
|
||||||
|
today = state.today,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -628,6 +640,7 @@ private fun DayColumnCard(
|
|||||||
blocks: List<TimedBlock>,
|
blocks: List<TimedBlock>,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -671,6 +684,10 @@ private fun DayColumnCard(
|
|||||||
.padding(horizontal = 1.dp),
|
.padding(horizontal = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Current-time line, on top of the events, only on today's column.
|
||||||
|
if (date == today) {
|
||||||
|
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
app/src/main/res/drawable/ic_notification_dismiss.xml
Normal file
12
app/src/main/res/drawable/ic_notification_dismiss.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Monochrome notification-action mark: Material "close" glyph. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable/ic_notification_snooze.xml
Normal file
12
app/src/main/res/drawable/ic_notification_snooze.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Monochrome notification-action mark: Material "snooze" glyph. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M7.88,3.39L6.6,1.86 2,5.71l1.29,1.53 4.59,-3.85zM22,5.72l-4.6,-3.86 -1.29,1.53 4.6,3.86L22,5.72zM12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7zM9,11h3.63L9,15.2L9,17h6v-2h-3.63L15,10.8L15,9L9,9z" />
|
||||||
|
</vector>
|
||||||
18
app/src/main/res/drawable/ic_qs_new_event.xml
Normal file
18
app/src/main/res/drawable/ic_qs_new_event.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Quick Settings tile icon ("New event"): a flat calendar+plus glyph. QS tile
|
||||||
|
icons are tinted by the system from their alpha, so this is a bare white
|
||||||
|
glyph on a transparent background (no brand circle, unlike the launcher
|
||||||
|
shortcut icon). -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFFFF">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M11.5,10.5h1v2h2v1h-2v2h-1v-2h-2v-1h2z" />
|
||||||
|
</vector>
|
||||||
@@ -81,6 +81,7 @@
|
|||||||
<string name="event_edit_add_guest">Gast hinzufügen</string>
|
<string name="event_edit_add_guest">Gast hinzufügen</string>
|
||||||
<string name="event_edit_add_guest_hint">Gast per E-Mail hinzufügen…</string>
|
<string name="event_edit_add_guest_hint">Gast per E-Mail hinzufügen…</string>
|
||||||
<string name="event_edit_add_guest_from_contacts">Aus Kontakten hinzufügen</string>
|
<string name="event_edit_add_guest_from_contacts">Aus Kontakten hinzufügen</string>
|
||||||
|
<string name="event_edit_location_from_contacts">Adresse aus Kontakten wählen</string>
|
||||||
<string name="event_edit_remove_guest">Gast entfernen</string>
|
<string name="event_edit_remove_guest">Gast entfernen</string>
|
||||||
<string name="event_edit_attendee_required">Erforderlich</string>
|
<string name="event_edit_attendee_required">Erforderlich</string>
|
||||||
<string name="event_edit_attendee_optional">Optional</string>
|
<string name="event_edit_attendee_optional">Optional</string>
|
||||||
@@ -186,6 +187,15 @@
|
|||||||
<item quantity="one">%d Woche vorher</item>
|
<item quantity="one">%d Woche vorher</item>
|
||||||
<item quantity="other">%d Wochen vorher</item>
|
<item quantity="other">%d Wochen vorher</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<!-- Reine Zeitdauern (ohne „vorher"), z. B. die Schlummerdauer. -->
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d Minute</item>
|
||||||
|
<item quantity="other">%d Minuten</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d Stunde</item>
|
||||||
|
<item quantity="other">%d Stunden</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Geteilte Event-Strings -->
|
<!-- Geteilte Event-Strings -->
|
||||||
<string name="event_untitled">(Ohne Titel)</string>
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
@@ -203,6 +213,8 @@
|
|||||||
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
|
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
|
||||||
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
|
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
|
||||||
<string name="reminder_onboarding_skip_button">Später</string>
|
<string name="reminder_onboarding_skip_button">Später</string>
|
||||||
|
<string name="reminder_action_snooze">Schlummern</string>
|
||||||
|
<string name="reminder_action_dismiss">Verwerfen</string>
|
||||||
|
|
||||||
<!-- View-Switcher (M1) -->
|
<!-- View-Switcher (M1) -->
|
||||||
<string name="view_month">Monat</string>
|
<string name="view_month">Monat</string>
|
||||||
@@ -221,6 +233,14 @@
|
|||||||
<string name="agenda_empty_title">Nichts geplant</string>
|
<string name="agenda_empty_title">Nichts geplant</string>
|
||||||
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
|
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
|
||||||
|
|
||||||
|
<!-- Terminsuche -->
|
||||||
|
<string name="search_action">Suchen</string>
|
||||||
|
<string name="search_hint">Termine suchen</string>
|
||||||
|
<string name="search_back">Zurück</string>
|
||||||
|
<string name="search_clear">Löschen</string>
|
||||||
|
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
|
||||||
|
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
|
||||||
|
|
||||||
<!-- Startbildschirm-Widgets -->
|
<!-- Startbildschirm-Widgets -->
|
||||||
<string name="widget_agenda_title">Anstehend</string>
|
<string name="widget_agenda_title">Anstehend</string>
|
||||||
<string name="widget_agenda_label">Calendula Agenda</string>
|
<string name="widget_agenda_label">Calendula Agenda</string>
|
||||||
@@ -236,6 +256,11 @@
|
|||||||
<string name="shortcut_new_event_short">Neuer Termin</string>
|
<string name="shortcut_new_event_short">Neuer Termin</string>
|
||||||
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
|
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
|
||||||
|
|
||||||
|
<!-- Schnelleinstellungen-Kachel -->
|
||||||
|
<string name="qs_tile_new_event_label">Neuer Termin</string>
|
||||||
|
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
|
||||||
|
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
|
||||||
|
|
||||||
<!-- Kalender-Filter (M3) -->
|
<!-- Kalender-Filter (M3) -->
|
||||||
<string name="filter_title">Kalender</string>
|
<string name="filter_title">Kalender</string>
|
||||||
|
|
||||||
@@ -274,6 +299,7 @@
|
|||||||
<string name="settings_reliable_delivery">Zuverlässige Zustellung</string>
|
<string name="settings_reliable_delivery">Zuverlässige Zustellung</string>
|
||||||
<string name="settings_reliable_delivery_hint">Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen.</string>
|
<string name="settings_reliable_delivery_hint">Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen.</string>
|
||||||
<string name="settings_reliable_delivery_exempt">Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich.</string>
|
<string name="settings_reliable_delivery_exempt">Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich.</string>
|
||||||
|
<string name="settings_snooze_duration">Schlummerdauer</string>
|
||||||
<string name="settings_section_calendars">Kalender</string>
|
<string name="settings_section_calendars">Kalender</string>
|
||||||
<string name="settings_manage_calendars">Kalender verwalten</string>
|
<string name="settings_manage_calendars">Kalender verwalten</string>
|
||||||
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
<string name="event_edit_add_guest">Add guest</string>
|
<string name="event_edit_add_guest">Add guest</string>
|
||||||
<string name="event_edit_add_guest_hint">Add a guest by email…</string>
|
<string name="event_edit_add_guest_hint">Add a guest by email…</string>
|
||||||
<string name="event_edit_add_guest_from_contacts">Add from contacts</string>
|
<string name="event_edit_add_guest_from_contacts">Add from contacts</string>
|
||||||
|
<string name="event_edit_location_from_contacts">Pick address from contacts</string>
|
||||||
<string name="event_edit_remove_guest">Remove guest</string>
|
<string name="event_edit_remove_guest">Remove guest</string>
|
||||||
<string name="event_edit_attendee_required">Required</string>
|
<string name="event_edit_attendee_required">Required</string>
|
||||||
<string name="event_edit_attendee_optional">Optional</string>
|
<string name="event_edit_attendee_optional">Optional</string>
|
||||||
@@ -187,6 +188,15 @@
|
|||||||
<item quantity="one">%d week before</item>
|
<item quantity="one">%d week before</item>
|
||||||
<item quantity="other">%d weeks before</item>
|
<item quantity="other">%d weeks before</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<!-- Plain durations (no "before"), e.g. the snooze delay. -->
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d minute</item>
|
||||||
|
<item quantity="other">%d minutes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d hour</item>
|
||||||
|
<item quantity="other">%d hours</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Shared event strings -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
@@ -204,6 +214,8 @@
|
|||||||
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
||||||
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
||||||
<string name="reminder_onboarding_skip_button">Not now</string>
|
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||||
|
<string name="reminder_action_snooze">Snooze</string>
|
||||||
|
<string name="reminder_action_dismiss">Dismiss</string>
|
||||||
|
|
||||||
<!-- View switcher (M1) -->
|
<!-- View switcher (M1) -->
|
||||||
<string name="view_month">Month</string>
|
<string name="view_month">Month</string>
|
||||||
@@ -222,6 +234,14 @@
|
|||||||
<string name="agenda_empty_title">Nothing scheduled</string>
|
<string name="agenda_empty_title">Nothing scheduled</string>
|
||||||
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
|
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
|
||||||
|
|
||||||
|
<!-- Event search -->
|
||||||
|
<string name="search_action">Search</string>
|
||||||
|
<string name="search_hint">Search events</string>
|
||||||
|
<string name="search_back">Back</string>
|
||||||
|
<string name="search_clear">Clear</string>
|
||||||
|
<string name="search_idle_hint">Search your events by title, location or notes.</string>
|
||||||
|
<string name="search_empty">No events match “%1$s”.</string>
|
||||||
|
|
||||||
<!-- Home-screen widgets -->
|
<!-- Home-screen widgets -->
|
||||||
<string name="widget_agenda_title">Upcoming</string>
|
<string name="widget_agenda_title">Upcoming</string>
|
||||||
<string name="widget_agenda_label">Calendula agenda</string>
|
<string name="widget_agenda_label">Calendula agenda</string>
|
||||||
@@ -271,6 +291,7 @@
|
|||||||
<string name="settings_reliable_delivery">Reliable delivery</string>
|
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||||
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
|
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
|
||||||
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
|
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
|
||||||
|
<string name="settings_snooze_duration">Snooze duration</string>
|
||||||
<string name="settings_section_calendars">Calendars</string>
|
<string name="settings_section_calendars">Calendars</string>
|
||||||
<string name="settings_manage_calendars">Manage calendars</string>
|
<string name="settings_manage_calendars">Manage calendars</string>
|
||||||
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
|
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
|
||||||
@@ -349,6 +370,11 @@
|
|||||||
<string name="shortcut_new_event_short">New event</string>
|
<string name="shortcut_new_event_short">New event</string>
|
||||||
<string name="shortcut_new_event_long">Create a new event</string>
|
<string name="shortcut_new_event_long">Create a new event</string>
|
||||||
|
|
||||||
|
<!-- Quick Settings tile -->
|
||||||
|
<string name="qs_tile_new_event_label">New event</string>
|
||||||
|
<string name="settings_qs_tile">Add Quick Settings tile</string>
|
||||||
|
<string name="settings_qs_tile_hint">Add a “New event” tile to the Quick Settings panel.</string>
|
||||||
|
|
||||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
||||||
|
|
||||||
@@ -364,6 +390,6 @@
|
|||||||
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
|
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
|
||||||
<string name="crash_report_body_template">Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n</string>
|
<string name="crash_report_body_template">Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n</string>
|
||||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||||
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new</string>
|
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
||||||
<string name="report_issue_choose_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new/choose</string>
|
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
|
|
||||||
var calendarsResult: List<CalendarSource> = emptyList()
|
var calendarsResult: List<CalendarSource> = emptyList()
|
||||||
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||||
|
var searchResult: (String) -> List<EventInstance> = { _ -> emptyList() }
|
||||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||||
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||||
var exportableEventsResult: List<IcsEvent> = emptyList()
|
var exportableEventsResult: List<IcsEvent> = emptyList()
|
||||||
@@ -51,6 +52,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun calendars(): List<CalendarSource> = calendarsResult
|
override fun calendars(): List<CalendarSource> = calendarsResult
|
||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||||
instancesResult(beginMillis, endMillis)
|
instancesResult(beginMillis, endMillis)
|
||||||
|
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
|
||||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
||||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
eventColorPaletteResult(calendarId)
|
eventColorPaletteResult(calendarId)
|
||||||
|
|||||||
@@ -233,6 +233,23 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(0)
|
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `snooze duration defaults to 10 minutes`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.snoozeMinutes.first()).isEqualTo(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `snooze duration round-trips and clamps to at least one minute`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setSnoozeMinutes(30)
|
||||||
|
assertThat(prefs.snoozeMinutes.first()).isEqualTo(30)
|
||||||
|
prefs.setSnoozeMinutes(0)
|
||||||
|
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||||
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
|
|||||||
38
fastlane/metadata/android/en-US/changelogs/20800.txt
Normal file
38
fastlane/metadata/android/en-US/changelogs/20800.txt
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
### Added
|
||||||
|
- Find events fast. A search button in the top bar of every calendar view opens
|
||||||
|
a search box — type a couple of letters and matching events (by title,
|
||||||
|
location or description) appear, soonest first with past events below. Tap a
|
||||||
|
result to open it. Search covers your whole calendar, not just what's on
|
||||||
|
screen, and skips calendars you've hidden. A recurring event shows its next
|
||||||
|
occurrence rather than the date the series first started.
|
||||||
|
- A current-time line in the day and week views. A thin coloured line marks the
|
||||||
|
present moment across today's column, so you can see at a glance where you are
|
||||||
|
in the day. It updates every minute and only appears when today is in view.
|
||||||
|
- Add and remove event guests. The create/edit form now has a Guests section:
|
||||||
|
add people by email or pick them from your contacts, mark each as required or
|
||||||
|
optional, and remove them. Calendula never sends invitations itself (it has no
|
||||||
|
internet access) — it only records the guests; if the event lives on a synced
|
||||||
|
account, that account may email them when it syncs, and on a local calendar no
|
||||||
|
one is notified. The form tells you which applies. Picking a guest from
|
||||||
|
contacts needs no contacts permission.
|
||||||
|
- Pick a location from your contacts. A contacts button beside the location
|
||||||
|
field drops a contact's address straight into an event — handy for a meeting
|
||||||
|
at someone's home or office. Like the guest picker, it needs no contacts
|
||||||
|
permission.
|
||||||
|
- A "New event" Quick Settings tile. Add it to your quick settings to jump
|
||||||
|
straight into the new-event form from anywhere. Settings → New event has a
|
||||||
|
one-tap button to add the tile (Android 13+); on older versions you can add it
|
||||||
|
from the quick-settings editor.
|
||||||
|
- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
|
||||||
|
reminder; snooze hides it and brings it back after a delay you pick in
|
||||||
|
Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
|
||||||
|
system won't re-post a reminder on its own, so Calendula schedules an exact
|
||||||
|
alarm to bring a snoozed one back on time.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Event details now show each guest's email beneath their name, instead of only
|
||||||
|
when no name is available.
|
||||||
|
- Crash and problem reports now open on the project's public Codeberg tracker,
|
||||||
|
where anyone can register and file an issue. Nothing is sent automatically —
|
||||||
|
you still review the report and submit it yourself in the browser.
|
||||||
|
|
||||||
@@ -1,23 +1,64 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Reproducibility guard for the official F-Droid repo.
|
# Reproducibility guard for the official F-Droid repo (de.jeanlucmakiola.calendula).
|
||||||
#
|
#
|
||||||
# F-Droid only republishes OUR signed binary if a from-source build reproduces
|
# F-Droid only republishes OUR signed binary if a from-source build reproduces it
|
||||||
# it byte-for-byte. AGP's VCS-info injection writes
|
# byte-for-byte and the binary carries no extra signing blocks. If any invariant
|
||||||
# META-INF/version-control-info.textproto with build-environment git metadata
|
# below regresses, the official repo silently stalls on the last good version
|
||||||
# (commit hash / path) — env-dependent, and the one thing that broke
|
# (fails safe — but you'd be stuck on an old release without noticing). So fail
|
||||||
# reproducibility before we disabled it. It MUST stay off on the release build,
|
# loudly here, on every PR.
|
||||||
# or new versions silently stop publishing to the official repo (fails safe, but
|
#
|
||||||
# you'd be stuck on an old version without noticing). Fail loudly if it regresses.
|
# Each invariant was learned from a real fdroiddata CI rejection (see
|
||||||
|
# docs/fdroid-official/ and the v2.7.3 -> v2.7.5 history):
|
||||||
|
# 1. vcsInfo { include = false } — else AGP embeds env-dependent git
|
||||||
|
# metadata (META-INF/version-control-info.textproto) -> not reproducible.
|
||||||
|
# 2. no foojay toolchain resolver — F-Droid's offline source scanner
|
||||||
|
# rejects org.gradle.toolchains.foojay-resolver (it can fetch a JDK over
|
||||||
|
# the network at build time).
|
||||||
|
# 3. dependenciesInfo { includeInApk = false } — else AGP embeds a "Dependency
|
||||||
|
# metadata" block (id 0x504b4453) in the APK Signing Block, which F-Droid's
|
||||||
|
# binary scanner rejects as an extra signing block.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
F="app/build.gradle.kts"
|
|
||||||
|
|
||||||
# Match `vcsInfo { ... include = false ... }` within the block, tolerant of
|
APP="app/build.gradle.kts"
|
||||||
# whitespace/newlines (-z: whole file as one record; [^}] stays inside the block).
|
SETTINGS="settings.gradle.kts"
|
||||||
if grep -Pzoq 'vcsInfo\s*\{[^}]*include\s*=\s*false' "$F"; then
|
fail=0
|
||||||
echo "OK: release build disables AGP VCS-info — stays reproducible for F-Droid."
|
|
||||||
|
# 1. AGP VCS-info must be disabled on the release build. -z reads the whole file
|
||||||
|
# as one record so the match can span newlines; [^}] keeps it inside the block.
|
||||||
|
if grep -Pzoq 'vcsInfo\s*\{[^}]*include\s*=\s*false' "$APP"; then
|
||||||
|
echo "OK: vcsInfo { include = false } — no env-dependent VCS metadata embedded."
|
||||||
else
|
else
|
||||||
echo "ERROR: '$F' release build is missing 'vcsInfo { include = false }'." >&2
|
echo "ERROR: '$APP' release build is missing 'vcsInfo { include = false }'." >&2
|
||||||
echo "AGP would embed env-dependent git metadata (version-control-info.textproto)," >&2
|
echo " AGP would embed version-control-info.textproto, breaking reproducibility." >&2
|
||||||
echo "breaking F-Droid reproducible builds. Restore it. See docs/fdroid-official/." >&2
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. The foojay toolchain resolver must not be present in any Gradle script.
|
||||||
|
gradle_files=("$SETTINGS" "$APP")
|
||||||
|
[ -f build.gradle.kts ] && gradle_files+=(build.gradle.kts)
|
||||||
|
if grep -qi 'foojay' "${gradle_files[@]}"; then
|
||||||
|
echo "ERROR: foojay toolchain resolver found in: $(grep -li foojay "${gradle_files[@]}" | tr '\n' ' ')" >&2
|
||||||
|
echo " F-Droid's source scanner rejects org.gradle.toolchains.foojay-resolver" >&2
|
||||||
|
echo " (it can fetch a JDK over the network). Remove the plugin." >&2
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
echo "OK: no foojay toolchain resolver — offline build scanner stays happy."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. AGP dependency-metadata block must not be embedded in the APK.
|
||||||
|
if grep -Pzoq 'dependenciesInfo\s*\{[^}]*includeInApk\s*=\s*false' "$APP"; then
|
||||||
|
echo "OK: dependenciesInfo { includeInApk = false } — no extra APK signing block."
|
||||||
|
else
|
||||||
|
echo "ERROR: '$APP' is missing 'dependenciesInfo { includeInApk = false }'." >&2
|
||||||
|
echo " AGP would embed a 'Dependency metadata' block (0x504b4453) in the APK" >&2
|
||||||
|
echo " Signing Block, which F-Droid's binary scanner rejects." >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$fail" -ne 0 ]; then
|
||||||
|
echo >&2
|
||||||
|
echo "Reproducible-release invariant(s) violated — official F-Droid publishing would" >&2
|
||||||
|
echo "stall. Fix the above before merging. See docs/fdroid-official/." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
echo "All reproducible-release invariants hold."
|
||||||
|
|||||||
Reference in New Issue
Block a user