Fix review findings in search result actions (#80)

- don't request focus while the selection bar hides the field (crash on rotate)
- rank and date all-day hits by their own day, not by UTC
- query non-Latin tokens case-by-case instead of wildcarding them into a scan
- clear a stale delete receipt when search is reopened
- keep the deleted count when write access is revoked mid-batch
- match on the raw title, not the untitled placeholder
This commit is contained in:
2026-08-08 19:14:47 +02:00
parent 7b5de34822
commit 51a31e060a
11 changed files with 232 additions and 140 deletions

View File

@@ -593,15 +593,19 @@ class AndroidCalendarDataSource @Inject constructor(
ensureObserversRegistered() ensureObserversRegistered()
val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS) val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS)
if (tokens.isEmpty()) return emptyList() if (tokens.isEmpty()) return emptyList()
// Every token has to appear somewhere, so the groups are ANDed. This is // Only a pre-filter: EventSearch re-checks every token, including the
// only a pre-filter — EventSearch re-checks each token with proper // ones the cap above dropped.
// case folding, and re-checks the tokens dropped by the cap above. val patterns = tokens.map { likePatterns(it) }
val match = tokens.joinToString(" AND ") { val match = patterns.joinToString(" AND ") { variants ->
"(${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " + variants.joinToString(" OR ", prefix = "(", postfix = ")") {
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " + "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\')" "${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
}
} }
val args = tokens.flatMap { token: String -> List(3) { likePattern(token) } }.toTypedArray() val args = patterns
.flatMap { variants -> variants.flatMap { pattern -> List(3) { pattern } } }
.toTypedArray()
val selection = "($match) AND " + val selection = "($match) AND " +
"${CalendarContract.Events.DELETED} = 0 AND " + "${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.ORIGINAL_ID} IS NULL" "${CalendarContract.Events.ORIGINAL_ID} IS NULL"
@@ -617,9 +621,8 @@ class AndroidCalendarDataSource @Inject constructor(
while (c.moveToNext()) { while (c.moveToNext()) {
val description = reader.getString(SearchProjection.IDX_DESCRIPTION) val description = reader.getString(SearchProjection.IDX_DESCRIPTION)
val base = reader.toSearchResult() ?: continue val base = reader.toSearchResult() ?: continue
// A recurring master's DTSTART is the series start; show its // A recurring master's DTSTART is the series start; date the row
// nearest occurrence instead so the date is the one the user // by its nearest occurrence instead.
// actually cares about (and sorting reflects it).
val event = if (base.isRecurring) { val event = if (base.isRecurring) {
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) -> nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
base.copy( base.copy(
@@ -630,28 +633,40 @@ class AndroidCalendarDataSource @Inject constructor(
} else { } else {
base base
} }
out += SearchCandidate(event = event, description = description) // The raw title, so matching can't catch on the placeholder an
// untitled event is drawn with.
out += SearchCandidate(
event = event,
description = description,
title = reader.getString(SearchProjection.IDX_TITLE),
)
} }
out out
} ?: emptyList() } ?: emptyList()
} }
/** /**
* One token as a `LIKE` pattern. SQLite folds case for ASCII only, so a * One token as the `LIKE` patterns to OR together. SQLite folds case for
* non-ASCII cased letter is substituted with the single-character wildcard * ASCII only, so a token with cased non-ASCII letters is also queried lower-,
* `_` — "ärzte" queries as `%_rzte%` and so still reaches "Ärzte". That * upper- and title-cased, which is how "ärzte" reaches "Ärzte". Wildcarding
* matches more than it should on purpose; [EventSearch] does the real * those letters away instead would make "москва" `%______%` — a full scan.
* comparison. Uncased scripts stay literal, keeping the filter selective.
*/ */
private fun likePatterns(token: String): List<String> {
if (token.none { it.code > 127 && (it.isUpperCase() || it.isLowerCase()) }) {
return listOf(likePattern(token))
}
val lower = token.lowercase()
return listOf(token, lower, token.uppercase(), lower.replaceFirstChar(Char::uppercaseChar))
.distinct()
.map(::likePattern)
}
private fun likePattern(token: String): String { private fun likePattern(token: String): String {
val sb = StringBuilder("%") val sb = StringBuilder("%")
for (c in token) { for (c in token) {
when { // A literal wildcard from the query matches itself.
c.code > 127 && (c.isUpperCase() || c.isLowerCase()) -> sb.append('_') if (c == '%' || c == '_' || c == '\\') sb.append('\\')
// A literal wildcard from the query matches itself. sb.append(c)
c == '%' || c == '_' || c == '\\' -> sb.append('\\').append(c)
else -> sb.append(c)
}
} }
return sb.append('%').toString() return sb.append('%').toString()
} }

View File

@@ -19,9 +19,8 @@ interface CalendarRepository {
/** /**
* Candidate matches for [query] with hidden calendars removed; empty when * Candidate matches for [query] with hidden calendars removed; empty when
* [query] is blank. Searches the whole history/future (see * [query] is blank. Searches the whole history/future and leaves matching
* [CalendarDataSource.searchEvents]) and leaves ranking to * and ranking to [de.jeanlucmakiola.calendula.domain.EventSearch].
* [de.jeanlucmakiola.calendula.domain.EventSearch].
*/ */
suspend fun searchEvents(query: String): List<SearchCandidate> suspend fun searchEvents(query: String): List<SearchCandidate>

View File

@@ -180,8 +180,7 @@ internal object SearchProjection {
// display its nearest occurrence, not the series-start DTSTART. // display its nearest occurrence, not the series-start DTSTART.
CalendarContract.Events.RRULE, CalendarContract.Events.RRULE,
CalendarContract.Events.RDATE, CalendarContract.Events.RDATE,
// Ranked and excerpted, not just filtered on: a description-only hit has // Excerpted, not just filtered on: a hit has to show what it matched.
// to be able to show what it matched.
CalendarContract.Events.DESCRIPTION, CalendarContract.Events.DESCRIPTION,
) )

View File

@@ -1,8 +1,11 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number import kotlinx.datetime.number
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
@@ -10,6 +13,9 @@ import kotlin.time.Instant
data class SearchCandidate( data class SearchCandidate(
val event: EventInstance, val event: EventInstance,
val description: String? = null, val description: String? = null,
/** Null when the event has none: [EventInstance.title] is then a display
* placeholder, which the query must not catch on. */
val title: String? = event.title,
) )
/** A matched run inside one of a hit's texts, for highlighting. */ /** A matched run inside one of a hit's texts, for highlighting. */
@@ -21,10 +27,7 @@ data class DescriptionSnippet(
val spans: List<MatchSpan>, val spans: List<MatchSpan>,
) )
/** /** One result: the event, plus where the query matched it. */
* One ranked search result: the event, plus where the query matched so the row
* can show why it is in the list.
*/
data class SearchHit( data class SearchHit(
val event: EventInstance, val event: EventInstance,
val titleSpans: List<MatchSpan> = emptyList(), val titleSpans: List<MatchSpan> = emptyList(),
@@ -42,10 +45,7 @@ data class SearchMonth(
val hits: List<SearchHit>, val hits: List<SearchHit>,
) )
/** /** A finished search: a calendar of hits, plus the description-only ones. */
* A finished search: a calendar of hits, plus the ones that only turned up
* inside a description and would otherwise clutter it.
*/
data class SearchResults( data class SearchResults(
val months: List<SearchMonth>, val months: List<SearchMonth>,
val inDescriptions: List<SearchHit>, val inDescriptions: List<SearchHit>,
@@ -59,22 +59,15 @@ data class SearchResults(
/** /**
* Turning a typed query into the results list. * Turning a typed query into the results list.
* *
* Matching is token-wise: the query splits on whitespace and an event has to * An event has to carry *every* whitespace-separated token, each in any of
* satisfy *every* token, each in any of title / location / description. Case is * title / location / description; case is folded in Kotlin rather than by SQL's
* folded through Kotlin's [String.indexOf] rather than SQL's `LIKE`, which folds * ASCII-only `LIKE`, so "ärzte" finds "Ärzte".
* ASCII only — "ärzte" has to find "Ärzte".
* *
* Results are a calendar rather than a ranking: hits are grouped by the month * Results are a calendar, not a ranking: months from the current one, then
* they fall in, the current month first, then the months ahead, then the months * forwards, then backwards, and inside a month what is still to come before
* behind counting backwards. Inside a month what is still to come reads * what has passed. The day is the boundary, not the moment. A hit the query
* forwards and what has passed follows it, so an event tomorrow sits above one * only reached through its description is held back in
* from yesterday. The day is the boundary, not the moment — an event that ended * [SearchResults.inDescriptions] rather than dated among the real ones.
* at 19:00 is still today's business and keeps its place in today's order.
*
* An event the query only reached through its description is held back in
* [SearchResults.inDescriptions]: those are the matches that made search
* confusing when a stray word in a holiday's notes landed among events actually
* named for the query.
*/ */
object EventSearch { object EventSearch {
@@ -86,20 +79,15 @@ object EventSearch {
private val WHITESPACE = Regex("\\s+") private val WHITESPACE = Regex("\\s+")
/** /** Shared with the data layer so its SQL pre-filter tokenises identically. */
* The query as match tokens: whitespace-separated, blanks dropped. Shared
* with the data layer so its SQL pre-filter tokenises identically.
*/
fun tokenize(query: String): List<String> = fun tokenize(query: String): List<String> =
query.trim().split(WHITESPACE).filter { it.isNotEmpty() } query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
/** /**
* Search [candidates] for [query], dropping any that don't carry every token. * Search [candidates] for [query], dropping any that don't carry every token.
* [now] marks an individual event as over; [todayStart] is where an event * [now] marks a hit as over, [todayStart] is where it stops being current.
* stops being current, so everything still on today's page keeps its place * [zone] places each event on the calendar through the span rule, so an
* among the upcoming ones. [zone] resolves each event to a calendar month — * all-day event keeps its own day whichever side of UTC we are on (#82).
* via [spanFirstDay], so an all-day event doesn't slip into the month before
* it west of UTC (#82).
*/ */
fun search( fun search(
candidates: List<SearchCandidate>, candidates: List<SearchCandidate>,
@@ -111,15 +99,17 @@ object EventSearch {
val tokens = tokenize(query) val tokens = tokenize(query)
if (tokens.isEmpty()) return SearchResults(emptyList(), emptyList()) if (tokens.isEmpty()) return SearchResults(emptyList(), emptyList())
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now) } val today = todayStart.toLocalDateTime(zone).date
val order = compareBy<Matched> { if (it.event.end >= todayStart) 0 else 1 } val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today }
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 }
.thenComparator { a, b -> .thenComparator { a, b ->
// What is left reads forwards; what is behind us reads backwards // What is left reads forwards; what is behind us reads backwards
// from today, nearest first. // from today, nearest first.
if (a.event.end >= todayStart) { if (upcoming(a)) {
a.event.start.compareTo(b.event.start) a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
} else { } else {
b.event.start.compareTo(a.event.start) b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
} }
} }
@@ -130,11 +120,7 @@ object EventSearch {
) )
} }
/** /** Months from the current one, then forwards, then backwards. */
* The dated hits as months: the one we are in, then forwards, then backwards.
* Each month keeps the order it was handed, so a month holding both sides of
* today lists what is left before what has passed.
*/
private fun groupByMonth( private fun groupByMonth(
dated: List<Matched>, dated: List<Matched>,
zone: TimeZone, zone: TimeZone,
@@ -171,27 +157,26 @@ object EventSearch {
candidate: SearchCandidate, candidate: SearchCandidate,
tokens: List<String>, tokens: List<String>,
now: Instant, now: Instant,
zone: TimeZone,
): Matched? { ): Matched? {
val title = candidate.event.title val title = candidate.title?.takeIf { it.isNotBlank() }
val location = candidate.event.location?.takeIf { it.isNotBlank() } val location = candidate.event.location?.takeIf { it.isNotBlank() }
// Collapsed once so a multi-line description matches and snippets the // Collapsed so a multi-line description matches the way it is drawn.
// same way it will be drawn.
val description = candidate.description val description = candidate.description
?.replace(WHITESPACE, " ") ?.replace(WHITESPACE, " ")
?.trim() ?.trim()
?.takeIf { it.isNotEmpty() } ?.takeIf { it.isNotEmpty() }
// Mid-word counts as a match — German compounds mean "termin" has to keep // Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
// finding "Zahnarzttermin".
val present = { token: String -> val present = { token: String ->
title.contains(token, ignoreCase = true) || title?.contains(token, ignoreCase = true) == true ||
location?.contains(token, ignoreCase = true) == true || location?.contains(token, ignoreCase = true) == true ||
description?.contains(token, ignoreCase = true) == true description?.contains(token, ignoreCase = true) == true
} }
if (!tokens.all(present)) return null if (!tokens.all(present)) return null
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty() val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
val titleSpans = spansIn(title, tokens) val titleSpans = title?.let { spansIn(it, tokens) }.orEmpty()
val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty() val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty()
return Matched( return Matched(
hit = SearchHit( hit = SearchHit(
@@ -201,10 +186,10 @@ object EventSearch {
descriptionSnippet = description descriptionSnippet = description
?.takeIf { descriptionSpans.isNotEmpty() } ?.takeIf { descriptionSpans.isNotEmpty() }
?.let { snippet(it, descriptionSpans) }, ?.let { snippet(it, descriptionSpans) },
isPast = candidate.event.end < now, isPast = candidate.event.dayEnd(zone) < now,
), ),
// Nothing in the name or the place the query could have caught on — // Nothing in the name or the place caught the query: it is only here
// the only reason this event is here is buried in its notes. // for something in its notes.
descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(), descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(),
) )
} }
@@ -234,9 +219,8 @@ object EventSearch {
} }
/** /**
* A window of [description] around its first match, elided at both ends, with * A window of [description] around its first match, elided at both ends,
* the spans rebased onto the window. Anything that falls outside is dropped * with the spans rebased onto it and any falling outside dropped.
* the row shows one excerpt, not the whole note.
*/ */
private fun snippet(description: String, spans: List<MatchSpan>): DescriptionSnippet { private fun snippet(description: String, spans: List<MatchSpan>): DescriptionSnippet {
if (description.length <= SNIPPET_LENGTH) { if (description.length <= SNIPPET_LENGTH) {
@@ -258,4 +242,15 @@ object EventSearch {
private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) { private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) {
val event: EventInstance get() = hit.event val event: EventInstance get() = hit.event
} }
/**
* The event's first day as an instant in [zone]. An all-day event sits at
* UTC midnight, so its raw start would rank it on a day it isn't drawn on.
*/
private fun EventInstance.dayStart(zone: TimeZone): Instant =
if (isAllDay) spanFirstDay(zone).atStartOfDayIn(zone) else start
/** The moment the event's last day is over in [zone], exclusive. */
private fun EventInstance.dayEnd(zone: TimeZone): Instant =
if (isAllDay) spanLastDay(zone).plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) else end
} }

View File

@@ -63,9 +63,8 @@ data class EventInstance(
val color: Int, val color: Int,
val location: String?, val location: String?,
/** /**
* Whether this row's event carries a recurrence rule. Only search results * Only search results, which read the series master, fill this in — the
* (which read the series master) fill this in — the Instances query already * Instances query already yields one row per occurrence.
* yields one row per occurrence, so it stays false there.
*/ */
val isRecurring: Boolean = false, val isRecurring: Boolean = false,
) )

View File

@@ -114,11 +114,9 @@ import java.time.format.FormatStyle
private const val CHIP_MILLIS = 4_000L private const val CHIP_MILLIS = 4_000L
/** /**
* Full-text event search (top-bar entry). Type a query → matching events * Full-text event search over title / location / description: tap a result to
* (title / location / description) across the whole calendar, newest-relevant * open its detail, long-press to select several and delete them at once (#80).
* first; tap a result to open its detail. Long-press a result to enter selection * A full-screen overlay hosted by [de.jeanlucmakiola.calendula.ui.CalendarHost].
* mode and delete several at once (#80). A full-screen overlay hosted by
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -138,12 +136,20 @@ fun SearchScreen(
val inSelection = selection.isNotEmpty() val inSelection = selection.isNotEmpty()
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
// Each fresh open starts blank and straight into typing. The ViewModel is // The ViewModel is activity-scoped, so a re-enter is what resets the last
// activity-scoped so it outlives the overlay; clearing on (re)enter is what // search, its selection and any delete receipt still up. Peeking a result
// resets a previous search. Peeking a result doesn't re-run this (the screen // doesn't re-run this the screen stays composed under the detail.
// stays composed under the detail), so backing out keeps the query.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.setQuery("") viewModel.setQuery("")
viewModel.clearSelection()
viewModel.consumeDeleteResult()
}
// The field only exists out of selection mode, so a recreation that lands
// mid-selection has to wait for the clear above before it can be focused.
var focusPending by remember { mutableStateOf(true) }
LaunchedEffect(inSelection) {
if (inSelection || !focusPending) return@LaunchedEffect
focusPending = false
focusRequester.requestFocus() focusRequester.requestFocus()
keyboard?.show() keyboard?.show()
} }
@@ -170,9 +176,9 @@ fun SearchScreen(
} }
} }
// Back leaves selection mode before it leaves the screen and without the // Back leaves selection mode before it leaves the screen, and without the
// predictive-back scale-out, which would read as closing search only to have // predictive-back scale-out — that would read as search closing and snapping
// it snap back. The two handlers are mutually exclusive on [inSelection]. // back. The two handlers are mutually exclusive on [inSelection].
BackHandler(enabled = inSelection) { viewModel.clearSelection() } BackHandler(enabled = inSelection) { viewModel.clearSelection() }
Scaffold( Scaffold(
@@ -193,9 +199,8 @@ fun SearchScreen(
) )
}, },
) { padding -> ) { padding ->
// imePadding shrinks the content by the keyboard, so the centered // imePadding so the idle/empty message re-centres above the keyboard and
// idle/empty message re-centres in the space above it (and the results // the results list lifts clear of it.
// list lifts clear of the keyboard too).
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -271,9 +276,8 @@ fun SearchScreen(
} }
/** /**
* The search field and the contextual selection bar as one bar: entering or * The search field and the contextual selection bar as one bar: the bar stays
* leaving selection mode keeps the bar itself in place, eases its container * in place across the swap, easing its colour and fading its slots through.
* colour and fades its slots through, rather than cutting between two bars.
*/ */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -298,8 +302,8 @@ private fun SearchTopBar(
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(), animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
label = "search-top-bar-color", label = "search-top-bar-color",
) )
// Held past the selection emptying so the count has something to draw while // Held past the selection emptying, so the count has something to draw while
// it fades out — by then the set is already at zero. // it fades out.
var lastCount by remember { mutableIntStateOf(0) } var lastCount by remember { mutableIntStateOf(0) }
if (selectedCount > 0) lastCount = selectedCount if (selectedCount > 0) lastCount = selectedCount
@@ -408,8 +412,7 @@ private fun SearchResults(
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
// No horizontal inset here: GroupedRow already insets itself, and adding // No horizontal inset: GroupedRow already insets itself.
// another 16dp made the results narrower than every other list.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) { ) {
results.results.months.forEach { month -> results.results.months.forEach { month ->
@@ -418,8 +421,7 @@ private fun SearchResults(
} }
hitRows(month.hits, results, selection, inSelection, onEventClick, onToggle) hitRows(month.hits, results, selection, inSelection, onEventClick, onToggle)
} }
// Held back from the calendar above: these matched nothing in the name or // Held back from the calendar above: matched only in the notes.
// the place, only somewhere in the notes.
if (results.results.inDescriptions.isNotEmpty()) { if (results.results.inDescriptions.isNotEmpty()) {
stickyHeader(key = "in-descriptions") { stickyHeader(key = "in-descriptions") {
SearchSectionHeader(text = stringResource(R.string.search_in_descriptions)) SearchSectionHeader(text = stringResource(R.string.search_in_descriptions))
@@ -514,8 +516,8 @@ private fun SearchResultRow(
color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary, color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary,
) )
GroupedRow( GroupedRow(
// Faded like a past event anywhere else in the app: search reaches back // Faded like a past event anywhere else in the app search reaches back
// through the whole history, so an old hit has to read as over. // through the whole history.
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier, modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
title = marked(event.title, hit.titleSpans, highlight), title = marked(event.title, hit.titleSpans, highlight),
summary = searchSummary(hit, highlight), summary = searchSummary(hit, highlight),
@@ -547,9 +549,8 @@ private fun SearchResultRow(
} }
/** /**
* The batch's receipt: how many events went, and whether any refused. Sits on * The batch's receipt: how many went, and whether any refused. No Undo — the
* the FAB band so the shortened list stays visible behind it. Deletes aren't * provider can't put a deleted event back.
* reversible through the provider, so this carries no Undo.
*/ */
@Composable @Composable
private fun DeleteOutcomeChip( private fun DeleteOutcomeChip(
@@ -570,7 +571,11 @@ private fun DeleteOutcomeChip(
deleteState.deleted, deleteState.deleted,
) )
} }
BulkDeleteUiState.NeedsPermission -> stringResource(R.string.event_delete_write_denied) is BulkDeleteUiState.NeedsPermission -> if (deleteState.deleted > 0) {
stringResource(R.string.search_delete_denied_partial, deleteState.deleted)
} else {
stringResource(R.string.event_delete_write_denied)
}
else -> null else -> null
} }
// Held past the state being consumed so the chip has something to draw while // Held past the state being consumed so the chip has something to draw while
@@ -606,10 +611,8 @@ private fun marked(text: String, spans: List<MatchSpan>, style: SpanStyle): Anno
} }
/** /**
* "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then * "Wed, 17 Jun 2026 · 09:00 · Office", with a second line excerpting the
* location, with a second line excerpting the description when that is where the * description when that is where the query matched.
* query matched. Without it a description-only hit reads as though it arrived
* from nowhere.
*/ */
@Composable @Composable
private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString { private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString {
@@ -619,9 +622,8 @@ private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString
val start = remember(event.start, zone) { val start = remember(event.start, zone) {
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone) JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
} }
// From the shared span rule, not [start]: an all-day event sits at UTC // From the span rule, not [start]: an all-day event sits at UTC midnight and
// midnight and would name the day before west of UTC (#82). The clock time // would name the day before west of UTC (#82).
// below stays device-zone — it is only rendered for timed events.
val dateText = remember(event.start, event.end, event.isAllDay, locale) { val dateText = remember(event.start, event.end, event.isAllDay, locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate()) .format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())

View File

@@ -47,11 +47,7 @@ sealed interface SearchUiState {
/** Matches by month — see [de.jeanlucmakiola.calendula.domain.EventSearch]. */ /** Matches by month — see [de.jeanlucmakiola.calendula.domain.EventSearch]. */
data class Results( data class Results(
val results: SearchResults, val results: SearchResults,
/** /** Rows from these are kept out of selection rather than failing later. */
* Calendars among the results that can't be written to (WebCal, birthday
* mirrors, …). Their rows are excluded from selection rather than failing
* at delete time.
*/
val readOnlyCalendarIds: Set<Long> = emptySet(), val readOnlyCalendarIds: Set<Long> = emptySet(),
) : SearchUiState { ) : SearchUiState {
val events: List<EventInstance> get() = results.allHits.map { it.event } val events: List<EventInstance> get() = results.allHits.map { it.event }
@@ -70,8 +66,8 @@ sealed interface BulkDeleteUiState {
/** Terminal: [deleted] events went, [failed] wouldn't. */ /** Terminal: [deleted] events went, [failed] wouldn't. */
data class Done(val deleted: Int, val failed: Int) : BulkDeleteUiState data class Done(val deleted: Int, val failed: Int) : BulkDeleteUiState
/** WRITE_CALENDAR was revoked mid-flight; nothing further was attempted. */ /** WRITE_CALENDAR was revoked after [deleted] went; the rest wasn't tried. */
data object NeedsPermission : BulkDeleteUiState data class NeedsPermission(val deleted: Int) : BulkDeleteUiState
} }
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -148,10 +144,7 @@ class SearchViewModel @Inject constructor(
_selection.value = emptySet() _selection.value = emptySet()
} }
/** /** Whether the batch needs a [RecurringWriteScope] decision before it runs. */
* Whether the current selection contains a recurring event, i.e. whether the
* batch needs a [RecurringWriteScope] decision before it can run.
*/
fun selectionHasRecurring(): Boolean { fun selectionHasRecurring(): Boolean {
val results = state.value as? SearchUiState.Results ?: return false val results = state.value as? SearchUiState.Results ?: return false
val picked = _selection.value val picked = _selection.value
@@ -159,10 +152,9 @@ class SearchViewModel @Inject constructor(
} }
/** /**
* Delete every selected event. [scope] applies to the recurring ones only * Delete every selected event. [scope] reaches the recurring ones only; a
* a one-off is always removed whole, whatever the batch decision was. Runs * one-off always goes whole. One at a time, so a single failure doesn't take
* one event at a time so a single failure doesn't take the rest with it; * the rest with it — the tally lands in [deleteState].
* the tally lands in [deleteState].
*/ */
fun deleteSelected(scope: RecurringWriteScope) { fun deleteSelected(scope: RecurringWriteScope) {
if (_deleteState.value == BulkDeleteUiState.Deleting) return if (_deleteState.value == BulkDeleteUiState.Deleting) return
@@ -192,7 +184,7 @@ class SearchViewModel @Inject constructor(
_selection.value = emptySet() _selection.value = emptySet()
_reload.value += 1 _reload.value += 1
_deleteState.value = if (denied) { _deleteState.value = if (denied) {
BulkDeleteUiState.NeedsPermission BulkDeleteUiState.NeedsPermission(deleted = deleted)
} else { } else {
BulkDeleteUiState.Done(deleted = deleted, failed = failed) BulkDeleteUiState.Done(deleted = deleted, failed = failed)
} }

View File

@@ -336,6 +336,7 @@
<item quantity="other">%d events deleted</item> <item quantity="other">%d events deleted</item>
</plurals> </plurals>
<string name="search_delete_partial">%1$d deleted, %2$d couldn\'t be</string> <string name="search_delete_partial">%1$d deleted, %2$d couldn\'t be</string>
<string name="search_delete_denied_partial">%1$d deleted, then write access was withdrawn</string>
<!-- Home-screen widgets --> <!-- Home-screen widgets -->
<string name="widget_agenda_title">Upcoming</string> <string name="widget_agenda_title">Upcoming</string>

View File

@@ -30,6 +30,10 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var existingUidsResult: Set<String> = emptySet() var existingUidsResult: Set<String> = emptySet()
/** Set to make the next write call throw. */ /** Set to make the next write call throw. */
var writeError: Exception? = null var writeError: Exception? = null
/** Deletes let through before [writeError] applies, for part-way failures. */
var deletesBeforeError: Int = 0
private var deletes = 0
/** Id returned by the next [insertEvent]. */ /** Id returned by the next [insertEvent]. */
var nextInsertId: Long = 100L var nextInsertId: Long = 100L
@@ -197,7 +201,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
} }
override fun deleteEvent(eventId: Long) { override fun deleteEvent(eventId: Long) {
writeError?.let { throw it } writeError?.let { if (deletes++ >= deletesBeforeError) throw it }
deletedEventIds += eventId deletedEventIds += eventId
managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } } managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } }
} }

View File

@@ -42,6 +42,24 @@ class EventSearchTest {
) )
} }
/** [day] is the event's own date; all-day events sit at UTC midnight. */
private fun allDayCandidate(title: String, day: String): SearchCandidate {
val begin = Instant.parse("${day}T00:00:00Z")
return SearchCandidate(
event = EventInstance(
instanceId = nextId,
eventId = nextId++,
calendarId = 1L,
title = title,
start = begin,
end = begin.plus(kotlin.time.Duration.parse("24h")),
isAllDay = true,
color = 0,
location = null,
),
)
}
private fun search(vararg candidates: SearchCandidate, query: String): SearchResults = private fun search(vararg candidates: SearchCandidate, query: String): SearchResults =
EventSearch.search(candidates.toList(), query, now, todayStart, zone) EventSearch.search(candidates.toList(), query, now, todayStart, zone)
@@ -189,6 +207,50 @@ class EventSearchTest {
assertThat(results.months.single().let { it.year to it.monthNumber }).isEqualTo(2027 to 2) assertThat(results.months.single().let { it.year to it.monthNumber }).isEqualTo(2027 to 2)
} }
@Test
fun `an all-day event that has passed does not rank as still to come`() {
// Ahead of UTC its UTC-midnight end lands after the local day started, so
// comparing raw instants floated yesterday's holiday above today's events.
val berlin = TimeZone.of("Europe/Berlin")
val yesterday = allDayCandidate("Test holiday", "2027-01-14")
val tomorrow = candidate("Test soon", start = "2027-01-16T09:00:00Z")
val results = EventSearch.search(
listOf(yesterday, tomorrow),
"test",
Instant.parse("2027-01-15T07:00:00Z"),
Instant.parse("2027-01-14T23:00:00Z"),
berlin,
)
assertThat(datedTitles(results)).containsExactly("Test soon", "Test holiday").inOrder()
assertThat(results.allHits.last().isPast).isTrue()
}
@Test
fun `an all-day event is not past until its own day is over`() {
// 21:00 in Los Angeles, where the event's UTC-midnight end is already gone.
val results = EventSearch.search(
listOf(allDayCandidate("Test holiday", "2027-01-15")),
"test",
Instant.parse("2027-01-16T05:00:00Z"),
Instant.parse("2027-01-15T08:00:00Z"),
TimeZone.of("America/Los_Angeles"),
)
assertThat(results.allHits.single().isPast).isFalse()
}
@Test
fun `an untitled event's placeholder is not something the query can match`() {
val untitled = candidate("(Ohne Titel)", description = "Titel folgt noch").copy(title = null)
val results = search(untitled, query = "titel")
assertThat(datedTitles(results)).isEmpty()
assertThat(results.inDescriptions.single().titleSpans).isEmpty()
}
@Test @Test
fun `a hit that has already finished is marked past, including earlier today`() { fun `a hit that has already finished is marked past, including earlier today`() {
val earlierToday = candidate("test", start = "2027-01-15T05:00:00Z") val earlierToday = candidate("test", start = "2027-01-15T05:00:00Z")

View File

@@ -202,6 +202,30 @@ class SearchViewModelTest {
job.cancel() job.cancel()
} }
@Test
fun `a permission revoked mid-batch still reports what already went`(@TempDir tempDir: Path) =
runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
searchResult = { listOf(hit(1L), hit(2L), hit(3L)) }
writeError = SecurityException("revoked")
deletesBeforeError = 1
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.setQuery("standup")
advanceUntilIdle()
vm.selectAll()
vm.deleteSelected(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(fake.deletedEventIds).hasSize(1)
assertThat(vm.deleteState.value)
.isEqualTo(BulkDeleteUiState.NeedsPermission(deleted = 1))
job.cancel()
}
@Test @Test
fun `changing the query drops the selection`(@TempDir tempDir: Path) = runTest(dispatcher) { fun `changing the query drops the selection`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { val fake = FakeCalendarDataSource().apply {