Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/167
This commit is contained in:
@@ -27,8 +27,10 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
@@ -62,12 +64,14 @@ interface CalendarDataSource {
|
||||
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): 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.
|
||||
* Master/one-off events that may match [query], across all calendars: every
|
||||
* whitespace-separated token has to appear in the title, description or
|
||||
* location. Reads the Events table directly so the search is unbounded in
|
||||
* time; exception rows are excluded (see [SearchProjection]). A deliberate
|
||||
* superset — [EventSearch] makes the final call and orders the hits.
|
||||
* [query] is assumed non-blank.
|
||||
*/
|
||||
fun searchEvents(query: String): List<EventInstance>
|
||||
fun searchEvents(query: String): List<SearchCandidate>
|
||||
|
||||
/**
|
||||
* The event-colour palette the calendar's account publishes
|
||||
@@ -585,20 +589,23 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||
}
|
||||
|
||||
override fun searchEvents(query: String): List<EventInstance> {
|
||||
override fun searchEvents(query: String): List<SearchCandidate> {
|
||||
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 tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS)
|
||||
if (tokens.isEmpty()) return emptyList()
|
||||
// Only a pre-filter: EventSearch re-checks every token, including the
|
||||
// ones the cap above dropped.
|
||||
val patterns = tokens.map { likePatterns(it) }
|
||||
val match = patterns.joinToString(" AND ") { variants ->
|
||||
variants.joinToString(" OR ", prefix = "(", postfix = ")") {
|
||||
"${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
||||
}
|
||||
}
|
||||
val args = patterns
|
||||
.flatMap { variants -> variants.flatMap { pattern -> List(3) { pattern } } }
|
||||
.toTypedArray()
|
||||
val selection = "($match) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||
@@ -606,19 +613,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
SearchProjection.COLUMNS,
|
||||
selection,
|
||||
arrayOf(like, like, like),
|
||||
args,
|
||||
CalendarContract.Events.DTSTART + " DESC",
|
||||
)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
val out = ArrayList<EventInstance>(c.count)
|
||||
val out = ArrayList<SearchCandidate>(c.count)
|
||||
while (c.moveToNext()) {
|
||||
val description = reader.getString(SearchProjection.IDX_DESCRIPTION)
|
||||
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) {
|
||||
// A recurring master's DTSTART is the series start; date the row
|
||||
// by its nearest occurrence instead.
|
||||
val event = if (base.isRecurring) {
|
||||
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
||||
base.copy(
|
||||
start = begin.toKotlinInstantFromEpochMillis(),
|
||||
@@ -628,11 +633,44 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
} else {
|
||||
base
|
||||
}
|
||||
// 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
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* One token as the `LIKE` patterns to OR together. SQLite folds case for
|
||||
* ASCII only, so a token with cased non-ASCII letters is also queried lower-,
|
||||
* upper- and title-cased, which is how "ärzte" reaches "Ärzte". Wildcarding
|
||||
* those letters away instead would make "москва" `%______%` — a full scan.
|
||||
*/
|
||||
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 {
|
||||
val sb = StringBuilder("%")
|
||||
for (c in token) {
|
||||
// A literal wildcard from the query matches itself.
|
||||
if (c == '%' || c == '_' || c == '\\') sb.append('\\')
|
||||
sb.append(c)
|
||||
}
|
||||
return sb.append('%').toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
||||
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
||||
@@ -1593,5 +1631,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
* next fires beyond it falls back to its series-start date.
|
||||
*/
|
||||
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Tokens the SQL pre-filter is built from. Bounds the statement for a
|
||||
* pasted paragraph; the ranker still requires every token, so a capped
|
||||
* search returns fewer rows to check, never more hits.
|
||||
*/
|
||||
const val MAX_SEARCH_TOKENS = 8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
@@ -17,11 +18,11 @@ interface CalendarRepository {
|
||||
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]).
|
||||
* Candidate matches for [query] with hidden calendars removed; empty when
|
||||
* [query] is blank. Searches the whole history/future and leaves matching
|
||||
* and ranking to [de.jeanlucmakiola.calendula.domain.EventSearch].
|
||||
*/
|
||||
suspend fun searchEvents(query: String): List<EventInstance>
|
||||
suspend fun searchEvents(query: String): List<SearchCandidate>
|
||||
|
||||
/**
|
||||
* The event-colour palette a calendar's account publishes; empty when it
|
||||
|
||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -162,13 +163,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
?: throw NoSuchEventException(eventId)
|
||||
}
|
||||
|
||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
|
||||
if (query.isBlank()) return@withContext emptyList()
|
||||
val excluded = prefs.hiddenCalendarIds.first() +
|
||||
prefs.pendingDisabledCalendarIds.first() +
|
||||
invisibleCalendarIds()
|
||||
dataSource.searchEvents(query)
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
|
||||
}
|
||||
|
||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
|
||||
@@ -180,6 +180,8 @@ internal object SearchProjection {
|
||||
// display its nearest occurrence, not the series-start DTSTART.
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.RDATE,
|
||||
// Excerpted, not just filtered on: a hit has to show what it matched.
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
@@ -194,6 +196,7 @@ internal object SearchProjection {
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_RRULE = 10
|
||||
const val IDX_RDATE = 11
|
||||
const val IDX_DESCRIPTION = 12
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,5 +40,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
||||
color = color,
|
||||
location = getString(SearchProjection.IDX_LOCATION),
|
||||
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** An event with the free text the search may have matched it on. */
|
||||
data class SearchCandidate(
|
||||
val event: EventInstance,
|
||||
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. */
|
||||
data class MatchSpan(val start: Int, val end: Int)
|
||||
|
||||
/** The part of a description a token matched, elided to what fits a row. */
|
||||
data class DescriptionSnippet(
|
||||
val text: String,
|
||||
val spans: List<MatchSpan>,
|
||||
)
|
||||
|
||||
/** One result: the event, plus where the query matched it. */
|
||||
data class SearchHit(
|
||||
val event: EventInstance,
|
||||
val titleSpans: List<MatchSpan> = emptyList(),
|
||||
val locationSpans: List<MatchSpan> = emptyList(),
|
||||
val descriptionSnippet: DescriptionSnippet? = null,
|
||||
/** Already over, so the row can say so. Finished-today counts. */
|
||||
val isPast: Boolean = false,
|
||||
)
|
||||
|
||||
/** One month's worth of hits, as the results list draws them under a header. */
|
||||
data class SearchMonth(
|
||||
val year: Int,
|
||||
/** 1–12, so the UI can build the month's first day for a localized label. */
|
||||
val monthNumber: Int,
|
||||
val hits: List<SearchHit>,
|
||||
)
|
||||
|
||||
/** A finished search: a calendar of hits, plus the description-only ones. */
|
||||
data class SearchResults(
|
||||
val months: List<SearchMonth>,
|
||||
val inDescriptions: List<SearchHit>,
|
||||
) {
|
||||
val isEmpty: Boolean get() = months.isEmpty() && inDescriptions.isEmpty()
|
||||
|
||||
/** Every hit, whichever section it sits in — for select-all and delete. */
|
||||
val allHits: List<SearchHit> get() = months.flatMap { it.hits } + inDescriptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Turning a typed query into the results list.
|
||||
*
|
||||
* An event has to carry *every* whitespace-separated token, each in any of
|
||||
* title / location / description; case is folded in Kotlin rather than by SQL's
|
||||
* ASCII-only `LIKE`, so "ärzte" finds "Ärzte".
|
||||
*
|
||||
* Results are a calendar, not a ranking: months from the current one, then
|
||||
* forwards, then backwards, and inside a month what is still to come before
|
||||
* what has passed. The day is the boundary, not the moment. A hit the query
|
||||
* only reached through its description is held back in
|
||||
* [SearchResults.inDescriptions] rather than dated among the real ones.
|
||||
*/
|
||||
object EventSearch {
|
||||
|
||||
/** Characters of description kept around the match in a row's snippet. */
|
||||
private const val SNIPPET_LENGTH = 96
|
||||
|
||||
/** Characters of lead-in shown before the match, when there is room. */
|
||||
private const val SNIPPET_LEAD = 24
|
||||
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
|
||||
/** Shared with the data layer so its SQL pre-filter tokenises identically. */
|
||||
fun tokenize(query: String): List<String> =
|
||||
query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Search [candidates] for [query], dropping any that don't carry every token.
|
||||
* [now] marks a hit as over, [todayStart] is where it stops being current.
|
||||
* [zone] places each event on the calendar through the span rule, so an
|
||||
* all-day event keeps its own day whichever side of UTC we are on (#82).
|
||||
*/
|
||||
fun search(
|
||||
candidates: List<SearchCandidate>,
|
||||
query: String,
|
||||
now: Instant,
|
||||
todayStart: Instant,
|
||||
zone: TimeZone,
|
||||
): SearchResults {
|
||||
val tokens = tokenize(query)
|
||||
if (tokens.isEmpty()) return SearchResults(emptyList(), emptyList())
|
||||
|
||||
val today = todayStart.toLocalDateTime(zone).date
|
||||
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 ->
|
||||
// What is left reads forwards; what is behind us reads backwards
|
||||
// from today, nearest first.
|
||||
if (upcoming(a)) {
|
||||
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
|
||||
} else {
|
||||
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
|
||||
}
|
||||
}
|
||||
|
||||
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
|
||||
return SearchResults(
|
||||
months = groupByMonth(dated, zone, todayStart),
|
||||
inDescriptions = inDescriptions.map { it.hit },
|
||||
)
|
||||
}
|
||||
|
||||
/** Months from the current one, then forwards, then backwards. */
|
||||
private fun groupByMonth(
|
||||
dated: List<Matched>,
|
||||
zone: TimeZone,
|
||||
todayStart: Instant,
|
||||
): List<SearchMonth> {
|
||||
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
|
||||
return dated
|
||||
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
|
||||
.entries
|
||||
.sortedWith(
|
||||
compareBy<Map.Entry<Int, List<Matched>>> {
|
||||
when {
|
||||
it.key == currentKey -> 0
|
||||
it.key > currentKey -> 1
|
||||
else -> 2
|
||||
}
|
||||
}.thenComparator { a, b ->
|
||||
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
|
||||
},
|
||||
)
|
||||
.map { (key, entries) ->
|
||||
SearchMonth(
|
||||
year = key / 12,
|
||||
monthNumber = key % 12 + 1,
|
||||
hits = entries.map { it.hit },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Year and month as one comparable number. */
|
||||
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
|
||||
|
||||
private fun match(
|
||||
candidate: SearchCandidate,
|
||||
tokens: List<String>,
|
||||
now: Instant,
|
||||
zone: TimeZone,
|
||||
): Matched? {
|
||||
val title = candidate.title?.takeIf { it.isNotBlank() }
|
||||
val location = candidate.event.location?.takeIf { it.isNotBlank() }
|
||||
// Collapsed so a multi-line description matches the way it is drawn.
|
||||
val description = candidate.description
|
||||
?.replace(WHITESPACE, " ")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
|
||||
val present = { token: String ->
|
||||
title?.contains(token, ignoreCase = true) == true ||
|
||||
location?.contains(token, ignoreCase = true) == true ||
|
||||
description?.contains(token, ignoreCase = true) == true
|
||||
}
|
||||
if (!tokens.all(present)) return null
|
||||
|
||||
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
|
||||
val titleSpans = title?.let { spansIn(it, tokens) }.orEmpty()
|
||||
val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty()
|
||||
return Matched(
|
||||
hit = SearchHit(
|
||||
event = candidate.event,
|
||||
titleSpans = titleSpans,
|
||||
locationSpans = locationSpans,
|
||||
descriptionSnippet = description
|
||||
?.takeIf { descriptionSpans.isNotEmpty() }
|
||||
?.let { snippet(it, descriptionSpans) },
|
||||
isPast = candidate.event.dayEnd(zone) < now,
|
||||
),
|
||||
// Nothing in the name or the place caught the query: it is only here
|
||||
// for something in its notes.
|
||||
descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Every occurrence of every token in [text], merged where they overlap. */
|
||||
private fun spansIn(text: String, tokens: List<String>): List<MatchSpan> {
|
||||
val raw = mutableListOf<MatchSpan>()
|
||||
for (token in tokens) {
|
||||
var index = text.indexOf(token, startIndex = 0, ignoreCase = true)
|
||||
while (index >= 0) {
|
||||
raw += MatchSpan(index, index + token.length)
|
||||
index = text.indexOf(token, startIndex = index + 1, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
if (raw.isEmpty()) return emptyList()
|
||||
val sorted = raw.sortedBy { it.start }
|
||||
val merged = mutableListOf(sorted.first())
|
||||
for (span in sorted.drop(1)) {
|
||||
val last = merged.last()
|
||||
if (span.start <= last.end) {
|
||||
merged[merged.lastIndex] = MatchSpan(last.start, maxOf(last.end, span.end))
|
||||
} else {
|
||||
merged += span
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* A window of [description] around its first match, elided at both ends,
|
||||
* with the spans rebased onto it and any falling outside dropped.
|
||||
*/
|
||||
private fun snippet(description: String, spans: List<MatchSpan>): DescriptionSnippet {
|
||||
if (description.length <= SNIPPET_LENGTH) {
|
||||
return DescriptionSnippet(description, spans)
|
||||
}
|
||||
val first = spans.first().start
|
||||
val start = (first - SNIPPET_LEAD).coerceIn(0, (description.length - SNIPPET_LENGTH))
|
||||
val end = (start + SNIPPET_LENGTH).coerceAtMost(description.length)
|
||||
val prefix = if (start > 0) "…" else ""
|
||||
val suffix = if (end < description.length) "…" else ""
|
||||
val text = prefix + description.substring(start, end) + suffix
|
||||
val shift = prefix.length - start
|
||||
val rebased = spans
|
||||
.filter { it.start >= start && it.end <= end }
|
||||
.map { MatchSpan(it.start + shift, it.end + shift) }
|
||||
return DescriptionSnippet(text, rebased)
|
||||
}
|
||||
|
||||
private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) {
|
||||
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
|
||||
}
|
||||
@@ -62,6 +62,11 @@ data class EventInstance(
|
||||
val isAllDay: Boolean,
|
||||
val color: Int,
|
||||
val location: String?,
|
||||
/**
|
||||
* Only search results, which read the series master, fill this in — the
|
||||
* Instances query already yields one row per occurrence.
|
||||
*/
|
||||
val isRecurring: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,67 +1,108 @@
|
||||
package de.jeanlucmakiola.calendula.ui.search
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
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.Row
|
||||
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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
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.Delete
|
||||
import androidx.compose.material.icons.filled.SearchOff
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
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.TextButton
|
||||
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.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.MatchSpan
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SnackChip
|
||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.Instant as JavaInstant
|
||||
@@ -69,11 +110,13 @@ import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
/** How long the delete confirmation chip stays up, matching a short snackbar. */
|
||||
private const val CHIP_MILLIS = 4_000L
|
||||
|
||||
/**
|
||||
* 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].
|
||||
* Full-text event search over title / location / description: tap a result to
|
||||
* open its detail, long-press to select several and delete them at once (#80).
|
||||
* A full-screen overlay hosted by [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -85,63 +128,79 @@ fun SearchScreen(
|
||||
) {
|
||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val selection by viewModel.selection.collectAsStateWithLifecycle()
|
||||
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val context = LocalContext.current
|
||||
val inSelection = selection.isNotEmpty()
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// 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.
|
||||
// The ViewModel is activity-scoped, so a re-enter is what resets the last
|
||||
// search, its selection and any delete receipt still up. Peeking a result
|
||||
// doesn't re-run this — the screen stays composed under the detail.
|
||||
LaunchedEffect(Unit) {
|
||||
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()
|
||||
keyboard?.show()
|
||||
}
|
||||
|
||||
// Same in-place WRITE_CALENDAR upgrade the detail screen does: a v1.0 install
|
||||
// holds only READ_CALENDAR, and granting continues into the held delete.
|
||||
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) pendingWrite?.invoke()
|
||||
pendingWrite = null
|
||||
}
|
||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) {
|
||||
action()
|
||||
} else {
|
||||
pendingWrite = action
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
|
||||
// Back leaves selection mode before it leaves the screen, and without the
|
||||
// predictive-back scale-out — that would read as search closing and snapping
|
||||
// back. The two handlers are mutually exclusive on [inSelection].
|
||||
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.predictiveBack(onBack = onBack),
|
||||
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
InlineTextField(
|
||||
value = query,
|
||||
onValueChange = viewModel::setQuery,
|
||||
placeholder = stringResource(R.string.search_hint),
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
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,
|
||||
),
|
||||
SearchTopBar(
|
||||
inSelection = inSelection,
|
||||
query = query,
|
||||
selectedCount = selection.size,
|
||||
focusRequester = focusRequester,
|
||||
onQueryChange = viewModel::setQuery,
|
||||
onImeAction = { keyboard?.hide() },
|
||||
onBack = onBack,
|
||||
onCloseSelection = viewModel::clearSelection,
|
||||
onSelectAll = viewModel::selectAll,
|
||||
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||
)
|
||||
},
|
||||
) { 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).
|
||||
// imePadding so the idle/empty message re-centres above the keyboard and
|
||||
// the results list lifts clear of it.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -158,52 +217,314 @@ fun SearchScreen(
|
||||
text = stringResource(R.string.search_empty, s.query),
|
||||
)
|
||||
is SearchUiState.Results -> SearchResults(
|
||||
events = s.events,
|
||||
results = s,
|
||||
selection = selection,
|
||||
inSelection = inSelection,
|
||||
onEventClick = onEventClick,
|
||||
onToggle = viewModel::toggleSelection,
|
||||
)
|
||||
}
|
||||
DeleteOutcomeChip(
|
||||
deleteState = deleteState,
|
||||
onConsume = viewModel::consumeDeleteResult,
|
||||
modifier = Modifier.align(Alignment.BottomStart),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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),
|
||||
modifier = animateItemMotion(),
|
||||
onClick = { onEventClick(event) },
|
||||
if (showDeleteDialog) {
|
||||
val count = selection.size
|
||||
// One decision for the whole batch: the scope question is only asked when
|
||||
// a recurring event is in it, and one-offs ignore whatever comes back.
|
||||
if (viewModel.selectionHasRecurring()) {
|
||||
RecurringScopeDialog(
|
||||
title = stringResource(R.string.event_delete_recurring_title),
|
||||
onSelect = { scope ->
|
||||
showDeleteDialog = false
|
||||
viewModel.deleteSelected(scope)
|
||||
},
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
)
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = {
|
||||
Text(pluralStringResource(R.plurals.search_delete_title, count, count))
|
||||
},
|
||||
text = { Text(stringResource(R.string.event_delete_body)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteDialog = false
|
||||
viewModel.deleteSelected(RecurringWriteScope.AllEvents)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.event_detail_delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The search field and the contextual selection bar as one bar: the bar stays
|
||||
* in place across the swap, easing its colour and fading its slots through.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun SearchTopBar(
|
||||
inSelection: Boolean,
|
||||
query: String,
|
||||
selectedCount: Int,
|
||||
focusRequester: FocusRequester,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onImeAction: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onCloseSelection: () -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val container by animateColorAsState(
|
||||
targetValue = if (inSelection) {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
label = "search-top-bar-color",
|
||||
)
|
||||
// Held past the selection emptying, so the count has something to draw while
|
||||
// it fades out.
|
||||
var lastCount by remember { mutableIntStateOf(0) }
|
||||
if (selectedCount > 0) lastCount = selectedCount
|
||||
|
||||
// Read here so the non-composable transitionSpec lambdas can capture it.
|
||||
val slotSwap = fadeThrough()
|
||||
|
||||
TopAppBar(
|
||||
title = {
|
||||
AnimatedContent(
|
||||
targetState = inSelection,
|
||||
transitionSpec = { slotSwap },
|
||||
label = "search-top-bar-title",
|
||||
) { selecting ->
|
||||
if (selecting) {
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.search_selected_count,
|
||||
lastCount,
|
||||
lastCount,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
InlineTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = stringResource(R.string.search_hint),
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Search,
|
||||
onImeAction = onImeAction,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
AnimatedContent(
|
||||
targetState = inSelection,
|
||||
transitionSpec = { slotSwap },
|
||||
label = "search-top-bar-navigation",
|
||||
) { selecting ->
|
||||
if (selecting) {
|
||||
IconButton(onClick = onCloseSelection) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.search_selection_close),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.search_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
AnimatedContent(
|
||||
targetState = inSelection,
|
||||
transitionSpec = { slotSwap },
|
||||
label = "search-top-bar-actions",
|
||||
) { selecting ->
|
||||
Row {
|
||||
if (selecting) {
|
||||
IconButton(onClick = onSelectAll) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SelectAll,
|
||||
contentDescription = stringResource(R.string.search_select_all),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(
|
||||
R.string.search_delete_selected,
|
||||
),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
} else if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.search_clear),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = container),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SearchResults(
|
||||
results: SearchUiState.Results,
|
||||
selection: Set<Long>,
|
||||
inSelection: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onToggle: (Long) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// No horizontal inset: GroupedRow already insets itself.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
results.results.months.forEach { month ->
|
||||
stickyHeader(key = "month-${month.year}-${month.monthNumber}") {
|
||||
SearchSectionHeader(text = monthLabel(month))
|
||||
}
|
||||
hitRows(month.hits, results, selection, inSelection, onEventClick, onToggle)
|
||||
}
|
||||
// Held back from the calendar above: matched only in the notes.
|
||||
if (results.results.inDescriptions.isNotEmpty()) {
|
||||
stickyHeader(key = "in-descriptions") {
|
||||
SearchSectionHeader(text = stringResource(R.string.search_in_descriptions))
|
||||
}
|
||||
hitRows(
|
||||
results.results.inDescriptions,
|
||||
results,
|
||||
selection,
|
||||
inSelection,
|
||||
onEventClick,
|
||||
onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One section's rows, sharing a grouped-card run. */
|
||||
private fun LazyListScope.hitRows(
|
||||
hits: List<SearchHit>,
|
||||
results: SearchUiState.Results,
|
||||
selection: Set<Long>,
|
||||
inSelection: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onToggle: (Long) -> Unit,
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = hits,
|
||||
key = { _, hit -> hit.event.eventId },
|
||||
) { index, hit ->
|
||||
val event = hit.event
|
||||
val deletable = results.isDeletable(event)
|
||||
SearchResultRow(
|
||||
hit = hit,
|
||||
position = positionOf(index, hits.size),
|
||||
modifier = animateItemMotion(),
|
||||
selected = event.eventId in selection,
|
||||
// A read-only calendar's row can't join a batch, so in selection
|
||||
// mode it goes quiet rather than offering a checkbox that fails.
|
||||
inSelection = inSelection,
|
||||
selectable = deletable,
|
||||
onClick = when {
|
||||
inSelection && deletable -> ({ onToggle(event.eventId) })
|
||||
inSelection -> null
|
||||
else -> ({ onEventClick(event) })
|
||||
},
|
||||
onLongClick = { onToggle(event.eventId) }.takeIf { deletable && !inSelection },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pinned section label, styled like the agenda's day headers. */
|
||||
@Composable
|
||||
private fun SearchSectionHeader(text: String) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "August 2026" in the locale's own field order. */
|
||||
@Composable
|
||||
private fun monthLabel(month: SearchMonth): String {
|
||||
val locale = currentLocale()
|
||||
return remember(month.year, month.monthNumber, locale) {
|
||||
localizedDateFormatter(locale, "LLLLy")
|
||||
.format(java.time.LocalDate.of(month.year, month.monthNumber, 1))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResultRow(
|
||||
event: EventInstance,
|
||||
hit: SearchHit,
|
||||
position: Position,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
selected: Boolean = false,
|
||||
inSelection: Boolean = false,
|
||||
selectable: Boolean = true,
|
||||
onClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val event = hit.event
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
// On a picked row the headline is already recoloured for the secondary
|
||||
// container, so the match is carried by weight alone there.
|
||||
val highlight = SpanStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
GroupedRow(
|
||||
modifier = modifier,
|
||||
title = event.title,
|
||||
summary = searchSummary(event),
|
||||
// Faded like a past event anywhere else in the app — search reaches back
|
||||
// through the whole history.
|
||||
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = marked(event.title, hit.titleSpans, highlight),
|
||||
summary = searchSummary(hit, highlight),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
selected = selected,
|
||||
dimmed = inSelection && !selectable,
|
||||
leading = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -212,21 +533,97 @@ private fun SearchResultRow(
|
||||
.background(eventAccent(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
trailing = if (inSelection && selectable) {
|
||||
{
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
|
||||
/**
|
||||
* The batch's receipt: how many went, and whether any refused. No Undo — the
|
||||
* provider can't put a deleted event back.
|
||||
*/
|
||||
@Composable
|
||||
private fun searchSummary(event: EventInstance): String {
|
||||
private fun DeleteOutcomeChip(
|
||||
deleteState: BulkDeleteUiState,
|
||||
onConsume: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val message = when (deleteState) {
|
||||
is BulkDeleteUiState.Done -> when {
|
||||
deleteState.failed > 0 -> stringResource(
|
||||
R.string.search_delete_partial,
|
||||
deleteState.deleted,
|
||||
deleteState.failed,
|
||||
)
|
||||
else -> pluralStringResource(
|
||||
R.plurals.search_delete_done,
|
||||
deleteState.deleted,
|
||||
deleteState.deleted,
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
||||
// Held past the state being consumed so the chip has something to draw while
|
||||
// it springs back out.
|
||||
val shown = remember { mutableStateOf("") }
|
||||
if (message != null && shown.value != message) shown.value = message
|
||||
|
||||
LaunchedEffect(deleteState) {
|
||||
if (message == null) return@LaunchedEffect
|
||||
delay(CHIP_MILLIS)
|
||||
onConsume()
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
||||
.height(SnackChipHeight),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
SnackChip(visible = message != null, message = shown.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** [text] with every matched run emphasised. */
|
||||
private fun marked(text: String, spans: List<MatchSpan>, style: SpanStyle): AnnotatedString =
|
||||
if (spans.isEmpty()) {
|
||||
AnnotatedString(text)
|
||||
} else {
|
||||
buildAnnotatedString {
|
||||
append(text)
|
||||
spans.forEach { addStyle(style, it.start, it.end) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Wed, 17 Jun 2026 · 09:00 · Office", with a second line excerpting the
|
||||
* description when that is where the query matched.
|
||||
*/
|
||||
@Composable
|
||||
private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString {
|
||||
val event = hit.event
|
||||
val locale = currentLocale()
|
||||
val zone = remember { ZoneId.systemDefault() }
|
||||
val start = remember(event.start, zone) {
|
||||
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
||||
}
|
||||
// From the shared span rule, not [start]: an all-day event sits at UTC
|
||||
// midnight and would name the day before west of UTC (#82). The clock time
|
||||
// below stays device-zone — it is only rendered for timed events.
|
||||
// From the span rule, not [start]: an all-day event sits at UTC midnight and
|
||||
// would name the day before west of UTC (#82).
|
||||
val dateText = remember(event.start, event.end, event.isAllDay, locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
||||
@@ -237,8 +634,23 @@ private fun searchSummary(event: EventInstance): String {
|
||||
} else {
|
||||
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||
}
|
||||
val base = "$dateText · $timeText"
|
||||
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
||||
return buildAnnotatedString {
|
||||
append("$dateText · $timeText")
|
||||
event.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||
append(" · ")
|
||||
val offset = length
|
||||
append(location)
|
||||
hit.locationSpans.forEach {
|
||||
addStyle(highlight, offset + it.start, offset + it.end)
|
||||
}
|
||||
}
|
||||
hit.descriptionSnippet?.let { snippet ->
|
||||
append("\n")
|
||||
val offset = length
|
||||
append(snippet.text)
|
||||
snippet.spans.forEach { addStyle(highlight, offset + it.start, offset + it.end) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -6,6 +6,10 @@ 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 de.jeanlucmakiola.calendula.domain.EventSearch
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SearchResults
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -14,12 +18,19 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -33,8 +44,30 @@ sealed interface 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
|
||||
/** Matches by month — see [de.jeanlucmakiola.calendula.domain.EventSearch]. */
|
||||
data class Results(
|
||||
val results: SearchResults,
|
||||
/** Rows from these are kept out of selection rather than failing later. */
|
||||
val readOnlyCalendarIds: Set<Long> = emptySet(),
|
||||
) : SearchUiState {
|
||||
val events: List<EventInstance> get() = results.allHits.map { it.event }
|
||||
|
||||
fun isDeletable(event: EventInstance): Boolean =
|
||||
event.calendarId !in readOnlyCalendarIds
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome of deleting the selected results (#80). */
|
||||
sealed interface BulkDeleteUiState {
|
||||
data object Idle : BulkDeleteUiState
|
||||
|
||||
data object Deleting : BulkDeleteUiState
|
||||
|
||||
/** Terminal: [deleted] events went, [failed] wouldn't. */
|
||||
data class Done(val deleted: Int, val failed: Int) : BulkDeleteUiState
|
||||
|
||||
/** WRITE_CALENDAR was revoked after [deleted] went; the rest wasn't tried. */
|
||||
data class NeedsPermission(val deleted: Int) : BulkDeleteUiState
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@@ -47,17 +80,42 @@ class SearchViewModel @Inject constructor(
|
||||
private val _query = MutableStateFlow("")
|
||||
val query: StateFlow<String> = _query.asStateFlow()
|
||||
|
||||
val state: StateFlow<SearchUiState> = _query
|
||||
.debounce(250L)
|
||||
.map { it.trim() }
|
||||
.distinctUntilChanged()
|
||||
/** Bumped after a delete so the same query re-runs against the changed provider. */
|
||||
private val _reload = MutableStateFlow(0)
|
||||
|
||||
private val _selection = MutableStateFlow<Set<Long>>(emptySet())
|
||||
|
||||
/** Event ids picked in selection mode; empty means the mode is off. */
|
||||
val selection: StateFlow<Set<Long>> = _selection.asStateFlow()
|
||||
|
||||
private val _deleteState = MutableStateFlow<BulkDeleteUiState>(BulkDeleteUiState.Idle)
|
||||
val deleteState: StateFlow<BulkDeleteUiState> = _deleteState.asStateFlow()
|
||||
|
||||
val state: StateFlow<SearchUiState> = combine(
|
||||
_query.debounce(250L).map { it.trim() }.distinctUntilChanged(),
|
||||
_reload,
|
||||
) { q, _ -> q }
|
||||
.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))
|
||||
val now = Clock.System.now()
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val results = EventSearch.search(
|
||||
candidates = repository.searchEvents(q),
|
||||
query = q,
|
||||
now = now,
|
||||
todayStart = now.toLocalDateTime(zone).date.atStartOfDayIn(zone),
|
||||
zone = zone,
|
||||
)
|
||||
if (results.isEmpty) {
|
||||
SearchUiState.Empty(q)
|
||||
} else {
|
||||
SearchUiState.Results(
|
||||
results = results,
|
||||
readOnlyCalendarIds = readOnlyCalendarIds(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.catch { emit(SearchUiState.Idle) }
|
||||
@@ -65,13 +123,94 @@ class SearchViewModel @Inject constructor(
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
||||
|
||||
fun setQuery(value: String) {
|
||||
if (value != _query.value) _selection.value = emptySet()
|
||||
_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 }
|
||||
/** Toggle one result; the first pick is what turns selection mode on. */
|
||||
fun toggleSelection(eventId: Long) {
|
||||
val current = _selection.value
|
||||
_selection.value = if (eventId in current) current - eventId else current + eventId
|
||||
}
|
||||
|
||||
/** Pick every result that lives in a writable calendar. */
|
||||
fun selectAll() {
|
||||
val results = state.value as? SearchUiState.Results ?: return
|
||||
_selection.value = results.events.filter(results::isDeletable).map { it.eventId }.toSet()
|
||||
}
|
||||
|
||||
/** Leave selection mode. */
|
||||
fun clearSelection() {
|
||||
_selection.value = emptySet()
|
||||
}
|
||||
|
||||
/** Whether the batch needs a [RecurringWriteScope] decision before it runs. */
|
||||
fun selectionHasRecurring(): Boolean {
|
||||
val results = state.value as? SearchUiState.Results ?: return false
|
||||
val picked = _selection.value
|
||||
return results.events.any { it.eventId in picked && it.isRecurring }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every selected event. [scope] reaches the recurring ones only; a
|
||||
* one-off always goes whole. One at a time, so a single failure doesn't take
|
||||
* the rest with it — the tally lands in [deleteState].
|
||||
*/
|
||||
fun deleteSelected(scope: RecurringWriteScope) {
|
||||
if (_deleteState.value == BulkDeleteUiState.Deleting) return
|
||||
val results = state.value as? SearchUiState.Results ?: return
|
||||
val picked = _selection.value
|
||||
val targets = results.events.filter { it.eventId in picked && results.isDeletable(it) }
|
||||
if (targets.isEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_deleteState.value = BulkDeleteUiState.Deleting
|
||||
var deleted = 0
|
||||
var failed = 0
|
||||
var denied = false
|
||||
for (event in targets) {
|
||||
try {
|
||||
withContext(io) { deleteOne(event, scope) }
|
||||
deleted++
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
denied = true
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
_selection.value = emptySet()
|
||||
_reload.value += 1
|
||||
_deleteState.value = if (denied) {
|
||||
BulkDeleteUiState.NeedsPermission(deleted = deleted)
|
||||
} else {
|
||||
BulkDeleteUiState.Done(deleted = deleted, failed = failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset [deleteState] after the screen showed the outcome. */
|
||||
fun consumeDeleteResult() {
|
||||
_deleteState.value = BulkDeleteUiState.Idle
|
||||
}
|
||||
|
||||
private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) {
|
||||
val begin = event.start.toEpochMilliseconds()
|
||||
when {
|
||||
!event.isRecurring -> repository.deleteEvent(event.eventId)
|
||||
scope == RecurringWriteScope.ThisEvent ->
|
||||
repository.deleteOccurrence(event.eventId, begin)
|
||||
scope == RecurringWriteScope.ThisAndFollowing ->
|
||||
repository.deleteEventFromOccurrence(event.eventId, begin)
|
||||
else -> repository.deleteEvent(event.eventId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readOnlyCalendarIds(): Set<Long> =
|
||||
repository.calendars().first()
|
||||
.filterNot { it.canModifyContents }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
@@ -318,6 +318,26 @@
|
||||
<string name="search_idle_hint">Search your events by title, location or notes.</string>
|
||||
<string name="search_empty">No events match “%1$s”.</string>
|
||||
|
||||
<!-- Selecting search results to delete several at once (#80) -->
|
||||
<plurals name="search_selected_count">
|
||||
<item quantity="one">%d selected</item>
|
||||
<item quantity="other">%d selected</item>
|
||||
</plurals>
|
||||
<string name="search_in_descriptions">Found in descriptions</string>
|
||||
<string name="search_selection_close">Cancel selection</string>
|
||||
<string name="search_select_all">Select all</string>
|
||||
<string name="search_delete_selected">Delete selected</string>
|
||||
<plurals name="search_delete_title">
|
||||
<item quantity="one">Delete %d event?</item>
|
||||
<item quantity="other">Delete %d events?</item>
|
||||
</plurals>
|
||||
<plurals name="search_delete_done">
|
||||
<item quantity="one">%d event deleted</item>
|
||||
<item quantity="other">%d events deleted</item>
|
||||
</plurals>
|
||||
<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 -->
|
||||
<string name="widget_agenda_title">Upcoming</string>
|
||||
<string name="widget_agenda_label">Calendula agenda</string>
|
||||
|
||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -451,15 +452,15 @@ class CalendarRepositoryImplTest {
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L))
|
||||
searchResult = {
|
||||
listOf(
|
||||
makeEvent(10L, "Shown", calendarId = 1L),
|
||||
makeEvent(11L, "Switched off", calendarId = 2L),
|
||||
makeEvent(12L, "Hidden", calendarId = 3L),
|
||||
SearchCandidate(makeEvent(10L, "Shown", calendarId = 1L)),
|
||||
SearchCandidate(makeEvent(11L, "Switched off", calendarId = 2L)),
|
||||
SearchCandidate(makeEvent(12L, "Hidden", calendarId = 3L)),
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
assertThat(repo.searchEvents("e").map { it.title }).containsExactly("Shown")
|
||||
assertThat(repo.searchEvents("e").map { it.event.title }).containsExactly("Shown")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
@@ -19,7 +20,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
|
||||
var calendarsResult: List<CalendarSource> = emptyList()
|
||||
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||
var searchResult: (String) -> List<EventInstance> = { _ -> emptyList() }
|
||||
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
|
||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||
var exportableEventsResult: List<IcsEvent> = emptyList()
|
||||
@@ -29,6 +30,10 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
var existingUidsResult: Set<String> = emptySet()
|
||||
/** Set to make the next write call throw. */
|
||||
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]. */
|
||||
var nextInsertId: Long = 100L
|
||||
|
||||
@@ -71,7 +76,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
}
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||
instancesResult(beginMillis, endMillis)
|
||||
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
|
||||
override fun searchEvents(query: String): List<SearchCandidate> = searchResult(query)
|
||||
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? =
|
||||
eventDetailResult(eventId)
|
||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
@@ -196,7 +201,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
}
|
||||
|
||||
override fun deleteEvent(eventId: Long) {
|
||||
writeError?.let { throw it }
|
||||
writeError?.let { if (deletes++ >= deletesBeforeError) throw it }
|
||||
deletedEventIds += eventId
|
||||
managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } }
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ class SearchMapperTest {
|
||||
eventColor: Any? = null,
|
||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||
location: String? = null,
|
||||
rrule: String? = null,
|
||||
rdate: String? = null,
|
||||
): MapColumnReader = MapColumnReader(
|
||||
SearchProjection.IDX_RRULE to rrule,
|
||||
SearchProjection.IDX_RDATE to rdate,
|
||||
SearchProjection.IDX_ID to id,
|
||||
SearchProjection.IDX_CALENDAR_ID to calendarId,
|
||||
SearchProjection.IDX_TITLE to title,
|
||||
@@ -42,4 +46,16 @@ class SearchMapperTest {
|
||||
fun `absent dtstart drops the search hit`() {
|
||||
assertThat(searchReader(dtstart = null).toSearchResult()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rule or an rdate marks the hit recurring (issue #80)`() {
|
||||
assertThat(searchReader().toSearchResult()!!.isRecurring).isFalse()
|
||||
assertThat(searchReader(rrule = "").toSearchResult()!!.isRecurring).isFalse()
|
||||
assertThat(
|
||||
searchReader(rrule = "FREQ=WEEKLY").toSearchResult()!!.isRecurring,
|
||||
).isTrue()
|
||||
assertThat(
|
||||
searchReader(rdate = "20260101T000000Z").toSearchResult()!!.isRecurring,
|
||||
).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Search matching and how the results are laid out (#80 follow-up): a calendar
|
||||
* of months rather than a ranking, with description-only hits held back.
|
||||
*/
|
||||
class EventSearchTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
|
||||
/** 2027-01-15T08:00Z — mid-month and mid-day, so both sides of each are testable. */
|
||||
private val now = Instant.parse("2027-01-15T08:00:00Z")
|
||||
private val todayStart = Instant.parse("2027-01-15T00:00:00Z")
|
||||
|
||||
private var nextId = 1L
|
||||
|
||||
private fun candidate(
|
||||
title: String,
|
||||
start: String = "2027-01-20T09:00:00Z",
|
||||
location: String? = null,
|
||||
description: String? = null,
|
||||
): SearchCandidate {
|
||||
val begin = Instant.parse(start)
|
||||
return SearchCandidate(
|
||||
event = EventInstance(
|
||||
instanceId = nextId,
|
||||
eventId = nextId++,
|
||||
calendarId = 1L,
|
||||
title = title,
|
||||
start = begin,
|
||||
end = begin.plus(kotlin.time.Duration.parse("1h")),
|
||||
isAllDay = false,
|
||||
color = 0,
|
||||
location = location,
|
||||
),
|
||||
description = description,
|
||||
)
|
||||
}
|
||||
|
||||
/** [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 =
|
||||
EventSearch.search(candidates.toList(), query, now, todayStart, zone)
|
||||
|
||||
private fun datedTitles(results: SearchResults): List<String> =
|
||||
results.months.flatMap { month -> month.hits.map { it.event.title } }
|
||||
|
||||
@Test
|
||||
fun `hits are grouped into months, this one first, then ahead, then back`() {
|
||||
val thisMonth = candidate("Test A", start = "2027-01-20T09:00:00Z")
|
||||
val nextMonth = candidate("Test B", start = "2027-02-03T09:00:00Z")
|
||||
val lastMonth = candidate("Test C", start = "2026-12-10T09:00:00Z")
|
||||
val yearBefore = candidate("Test D", start = "2026-03-10T09:00:00Z")
|
||||
|
||||
val results = search(yearBefore, nextMonth, lastMonth, thisMonth, query = "test")
|
||||
|
||||
assertThat(results.months.map { it.year to it.monthNumber })
|
||||
.containsExactly(2027 to 1, 2027 to 2, 2026 to 12, 2026 to 3)
|
||||
.inOrder()
|
||||
assertThat(datedTitles(results)).containsExactly("Test A", "Test B", "Test C", "Test D")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inside a month what is still to come comes before what has passed`() {
|
||||
// Both sides of today share one January card rather than two headers.
|
||||
val earlierThisMonth = candidate("Test past", start = "2027-01-05T09:00:00Z")
|
||||
val laterThisMonth = candidate("Test soon", start = "2027-01-20T09:00:00Z")
|
||||
|
||||
val results = search(earlierThisMonth, laterThisMonth, query = "test")
|
||||
|
||||
assertThat(results.months).hasSize(1)
|
||||
assertThat(datedTitles(results)).containsExactly("Test soon", "Test past").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event still running today keeps its place among the upcoming ones`() {
|
||||
// Reported: "test" at 19:00 having passed must not fall behind "test2" at
|
||||
// 23:00 — the day is the boundary, not the moment.
|
||||
val earlierToday = candidate("test", start = "2027-01-15T05:00:00Z")
|
||||
val laterToday = candidate("test2", start = "2027-01-15T20:00:00Z")
|
||||
|
||||
val results = search(laterToday, earlierToday, query = "test")
|
||||
|
||||
assertThat(datedTitles(results)).containsExactly("test", "test2").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a match tomorrow comes before one from yesterday`() {
|
||||
val yesterday = candidate("test", start = "2027-01-14T09:00:00Z")
|
||||
val tomorrow = candidate("test2", start = "2027-01-16T09:00:00Z")
|
||||
|
||||
val results = search(yesterday, tomorrow, query = "test")
|
||||
|
||||
assertThat(datedTitles(results)).containsExactly("test2", "test").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a description-only hit is held back from the dated months`() {
|
||||
// The original complaint: "protestantischer" in a holiday's notes landed
|
||||
// among the events actually named Test.
|
||||
val holiday = candidate(
|
||||
"Reformationstag",
|
||||
start = "2027-01-16T09:00:00Z",
|
||||
description = "Feiertag der protestantischer Kirchen",
|
||||
)
|
||||
val real = candidate("Test", start = "2027-06-01T09:00:00Z")
|
||||
|
||||
val results = search(holiday, real, query = "test")
|
||||
|
||||
assertThat(datedTitles(results)).containsExactly("Test")
|
||||
assertThat(results.inDescriptions.map { it.event.title }).containsExactly("Reformationstag")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a location match stays in the dated months`() {
|
||||
val located = candidate("Frühstück", location = "Büro")
|
||||
|
||||
val results = search(located, query = "büro")
|
||||
|
||||
assertThat(datedTitles(results)).containsExactly("Frühstück")
|
||||
assertThat(results.inDescriptions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event named for one token stays dated even if another is in the notes`() {
|
||||
val mixed = candidate("Zahnarzt", description = "Termin bestätigen")
|
||||
|
||||
val results = search(mixed, query = "zahnarzt termin")
|
||||
|
||||
assertThat(datedTitles(results)).containsExactly("Zahnarzt")
|
||||
assertThat(results.inDescriptions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every token has to match, in any field and any order`() {
|
||||
val both = candidate("Zahnarzt", description = "Termin bestätigen")
|
||||
val onlyOne = candidate("Zahnarzt")
|
||||
|
||||
val results = search(both, onlyOne, query = "termin zahnarzt")
|
||||
|
||||
assertThat(results.allHits.map { it.event.eventId }).containsExactly(both.event.eventId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mid-word match is still found, so compounds keep working`() {
|
||||
val compound = candidate("Zahnarzttermin")
|
||||
|
||||
assertThat(datedTitles(search(compound, query = "termin"))).containsExactly("Zahnarzttermin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case folds beyond ASCII`() {
|
||||
assertThat(datedTitles(search(candidate("ÄRZTE Termin"), query = "ärzte")))
|
||||
.containsExactly("ÄRZTE Termin")
|
||||
assertThat(datedTitles(search(candidate("ärzte"), query = "ÄRZTE")))
|
||||
.containsExactly("ärzte")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event is filed by its own day, not by UTC`() {
|
||||
// Behind UTC an all-day event sits at midnight UTC, which is the previous
|
||||
// month locally (#82) — the month has to come from the span's first day.
|
||||
val newYear = SearchCandidate(
|
||||
event = EventInstance(
|
||||
instanceId = 99L,
|
||||
eventId = 99L,
|
||||
calendarId = 1L,
|
||||
title = "Test holiday",
|
||||
start = Instant.parse("2027-02-01T00:00:00Z"),
|
||||
end = Instant.parse("2027-02-02T00:00:00Z"),
|
||||
isAllDay = true,
|
||||
color = 0,
|
||||
location = null,
|
||||
),
|
||||
)
|
||||
|
||||
val results = EventSearch.search(
|
||||
listOf(newYear),
|
||||
"test",
|
||||
now,
|
||||
todayStart,
|
||||
TimeZone.of("America/New_York"),
|
||||
)
|
||||
|
||||
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
|
||||
fun `a hit that has already finished is marked past, including earlier today`() {
|
||||
val earlierToday = candidate("test", start = "2027-01-15T05:00:00Z")
|
||||
val laterToday = candidate("test", start = "2027-01-15T20:00:00Z")
|
||||
|
||||
val hits = search(earlierToday, laterToday, query = "test")
|
||||
.allHits
|
||||
.associateBy { it.event.eventId }
|
||||
|
||||
assertThat(hits.getValue(earlierToday.event.eventId).isPast).isTrue()
|
||||
assertThat(hits.getValue(laterToday.event.eventId).isPast).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the title carries the spans to highlight, merged where they overlap`() {
|
||||
val hit = search(candidate("Test the tester"), query = "test").allHits.single()
|
||||
|
||||
assertThat(hit.titleSpans).containsExactly(MatchSpan(0, 4), MatchSpan(9, 13)).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a description snippet is excerpted around its match`() {
|
||||
val long = "x".repeat(300) + " geheimwort " + "y".repeat(300)
|
||||
|
||||
val snippet = search(candidate("Notiz", description = long), query = "geheimwort")
|
||||
.allHits
|
||||
.single()
|
||||
.descriptionSnippet
|
||||
|
||||
requireNotNull(snippet)
|
||||
assertThat(snippet.text).contains("geheimwort")
|
||||
assertThat(snippet.text.length).isLessThan(long.length)
|
||||
assertThat(snippet.text).startsWith("…")
|
||||
val span = snippet.spans.single()
|
||||
assertThat(snippet.text.substring(span.start, span.end)).isEqualTo("geheimwort")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title-only match carries no description snippet`() {
|
||||
val hit = search(candidate("Test", description = "nothing relevant here"), query = "test")
|
||||
.allHits
|
||||
.single()
|
||||
|
||||
assertThat(hit.descriptionSnippet).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-line description matches and snippets as one line`() {
|
||||
val snippet = search(
|
||||
candidate("Notiz", description = "erste Zeile\n\n zweite Zeile"),
|
||||
query = "zweite",
|
||||
).allHits.single().descriptionSnippet
|
||||
|
||||
requireNotNull(snippet)
|
||||
assertThat(snippet.text).isEqualTo("erste Zeile zweite Zeile")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank query matches nothing`() {
|
||||
assertThat(search(candidate("Test"), query = " ").isEmpty).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package de.jeanlucmakiola.calendula.ui.search
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Selecting search results and deleting the batch (#80): which repository call
|
||||
* each kind of hit routes to, and what a read-only calendar is allowed to join.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SearchViewModelTest {
|
||||
|
||||
private val dispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
|
||||
@AfterEach fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
private val begin = 1_800_000_000_000L
|
||||
|
||||
private fun cal(id: Long, canModify: Boolean = true) = CalendarSource(
|
||||
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
|
||||
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = canModify,
|
||||
)
|
||||
|
||||
private fun hit(
|
||||
id: Long,
|
||||
calendarId: Long = 1L,
|
||||
recurring: Boolean = false,
|
||||
startMillis: Long = begin,
|
||||
) = SearchCandidate(
|
||||
EventInstance(
|
||||
instanceId = id, eventId = id, calendarId = calendarId, title = "Standup $id",
|
||||
start = Instant.fromEpochMilliseconds(startMillis),
|
||||
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
|
||||
isAllDay = false, color = 0xFF000000.toInt(), location = null, isRecurring = recurring,
|
||||
),
|
||||
)
|
||||
|
||||
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): SearchViewModel {
|
||||
val prefs = CalendarPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(dispatcher),
|
||||
produceFile = { tempDir.resolve("search_prefs.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
val settings = SettingsPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(dispatcher),
|
||||
produceFile = { tempDir.resolve("search_settings.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
|
||||
return SearchViewModel(repo, dispatcher)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.activate(vm: SearchViewModel): Job = launch { vm.state.collect {} }
|
||||
|
||||
@Test
|
||||
fun `a batch of one-offs deletes each event whole`(@TempDir tempDir: Path) =
|
||||
runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L))
|
||||
searchResult = { listOf(hit(1L), hit(2L), hit(3L)) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.setQuery("standup")
|
||||
advanceUntilIdle()
|
||||
vm.toggleSelection(1L)
|
||||
vm.toggleSelection(3L)
|
||||
assertThat(vm.selectionHasRecurring()).isFalse()
|
||||
|
||||
vm.deleteSelected(RecurringWriteScope.AllEvents)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.deletedEventIds).containsExactly(1L, 3L)
|
||||
assertThat(fake.deletedOccurrences).isEmpty()
|
||||
assertThat(vm.selection.value).isEmpty()
|
||||
assertThat(vm.deleteState.value).isEqualTo(BulkDeleteUiState.Done(deleted = 2, failed = 0))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the batch scope reaches the recurring hits only`(@TempDir tempDir: Path) =
|
||||
runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L))
|
||||
searchResult = { listOf(hit(1L), hit(2L, recurring = true)) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.setQuery("standup")
|
||||
advanceUntilIdle()
|
||||
vm.selectAll()
|
||||
assertThat(vm.selectionHasRecurring()).isTrue()
|
||||
|
||||
vm.deleteSelected(RecurringWriteScope.ThisEvent)
|
||||
advanceUntilIdle()
|
||||
|
||||
// The one-off goes whole whatever the batch decided; only the series
|
||||
// sees the scope, cancelling the occurrence the result row stands for.
|
||||
assertThat(fake.deletedEventIds).containsExactly(1L)
|
||||
assertThat(fake.deletedOccurrences).containsExactly(2L to begin)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `this-and-following truncates the recurring hit from its shown occurrence`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L))
|
||||
searchResult = { listOf(hit(9L, recurring = true)) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.setQuery("standup")
|
||||
advanceUntilIdle()
|
||||
vm.toggleSelection(9L)
|
||||
vm.deleteSelected(RecurringWriteScope.ThisAndFollowing)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.deletedFromOccurrences).containsExactly(9L to begin)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read-only calendar's hit cannot be selected or deleted`(@TempDir tempDir: Path) =
|
||||
runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L), cal(2L, canModify = false))
|
||||
searchResult = { listOf(hit(1L, calendarId = 1L), hit(2L, calendarId = 2L)) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.setQuery("standup")
|
||||
advanceUntilIdle()
|
||||
|
||||
val results = vm.state.value as SearchUiState.Results
|
||||
assertThat(results.isDeletable(results.events.single { it.eventId == 2L })).isFalse()
|
||||
|
||||
vm.selectAll()
|
||||
assertThat(vm.selection.value).containsExactly(1L)
|
||||
|
||||
// Even a hand-toggled read-only row is dropped before the write.
|
||||
vm.toggleSelection(2L)
|
||||
vm.deleteSelected(RecurringWriteScope.AllEvents)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.deletedEventIds).containsExactly(1L)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failing delete leaves the rest of the batch alone`(@TempDir tempDir: Path) =
|
||||
runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L))
|
||||
searchResult = { listOf(hit(1L), hit(2L)) }
|
||||
writeError = IllegalStateException("provider said no")
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.setQuery("standup")
|
||||
advanceUntilIdle()
|
||||
vm.selectAll()
|
||||
vm.deleteSelected(RecurringWriteScope.AllEvents)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.deleteState.value)
|
||||
.isEqualTo(BulkDeleteUiState.Done(deleted = 0, failed = 2))
|
||||
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
|
||||
fun `changing the query drops the selection`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L))
|
||||
searchResult = { listOf(hit(1L)) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.setQuery("standup")
|
||||
advanceUntilIdle()
|
||||
vm.toggleSelection(1L)
|
||||
assertThat(vm.selection.value).isNotEmpty()
|
||||
|
||||
vm.setQuery("retro")
|
||||
assertThat(vm.selection.value).isEmpty()
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user