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:
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user