Add actions to search results (#80) (#167)

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/167
This commit is contained in:
Jean-Luc Makiola
2026-08-08 19:36:22 +02:00
parent 4e38866dfd
commit 007e8ab8f6
16 changed files with 1601 additions and 133 deletions

View File

@@ -27,8 +27,10 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.curatedForPicker import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventSearch
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder 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.MANAGED_UID_PREFIX
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
@@ -62,12 +64,14 @@ interface CalendarDataSource {
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
/** /**
* Master/one-off events whose title, description or location contains * Master/one-off events that may match [query], across all calendars: every
* [query] (case-insensitive), across all calendars, newest first. Reads the * whitespace-separated token has to appear in the title, description or
* Events table directly so the search is unbounded in time; exception rows * location. Reads the Events table directly so the search is unbounded in
* are excluded (see [SearchProjection]). [query] is assumed non-blank. * 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 * 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() )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
} }
override fun searchEvents(query: String): List<EventInstance> { override fun searchEvents(query: String): List<SearchCandidate> {
ensureObserversRegistered() ensureObserversRegistered()
val trimmed = query.trim() val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS)
if (trimmed.isEmpty()) return emptyList() if (tokens.isEmpty()) return emptyList()
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches // Only a pre-filter: EventSearch re-checks every token, including the
// itself instead of acting as a wildcard. // ones the cap above dropped.
val escaped = trimmed val patterns = tokens.map { likePatterns(it) }
.replace("\\", "\\\\") val match = patterns.joinToString(" AND ") { variants ->
.replace("%", "\\%") variants.joinToString(" OR ", prefix = "(", postfix = ")") {
.replace("_", "\\_") "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
val like = "%$escaped%"
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " + "${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'" "${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
}
}
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"
@@ -606,19 +613,17 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Events.CONTENT_URI, CalendarContract.Events.CONTENT_URI,
SearchProjection.COLUMNS, SearchProjection.COLUMNS,
selection, selection,
arrayOf(like, like, like), args,
CalendarContract.Events.DTSTART + " DESC", CalendarContract.Events.DTSTART + " DESC",
)?.use { c -> )?.use { c ->
val reader = CursorColumnReader(c) val reader = CursorColumnReader(c)
val out = ArrayList<EventInstance>(c.count) val out = ArrayList<SearchCandidate>(c.count)
while (c.moveToNext()) { while (c.moveToNext()) {
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 recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
out += if (recurring) {
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) -> nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
base.copy( base.copy(
start = begin.toKotlinInstantFromEpochMillis(), start = begin.toKotlinInstantFromEpochMillis(),
@@ -628,11 +633,44 @@ class AndroidCalendarDataSource @Inject constructor(
} else { } else {
base 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 out
} ?: emptyList() } ?: 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 * The occurrence of [eventId] nearest to now: the soonest upcoming one
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past 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. * next fires beyond it falls back to its series-start date.
*/ */
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000 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
} }
} }

View File

@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance 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.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
@@ -17,11 +18,11 @@ interface CalendarRepository {
suspend fun eventDetail(eventId: Long): EventDetail suspend fun eventDetail(eventId: Long): EventDetail
/** /**
* Events whose title, description or location contains [query], with hidden * Candidate matches for [query] with hidden calendars removed; empty when
* calendars removed and newest first. Empty when [query] is blank. Searches * [query] is blank. Searches the whole history/future and leaves matching
* the whole history/future (see [CalendarDataSource.searchEvents]). * 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 * The event-colour palette a calendar's account publishes; empty when it

View File

@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance 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.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -162,13 +163,13 @@ class CalendarRepositoryImpl @Inject constructor(
?: throw NoSuchEventException(eventId) ?: 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() if (query.isBlank()) return@withContext emptyList()
val excluded = prefs.hiddenCalendarIds.first() + val excluded = prefs.hiddenCalendarIds.first() +
prefs.pendingDisabledCalendarIds.first() + prefs.pendingDisabledCalendarIds.first() +
invisibleCalendarIds() invisibleCalendarIds()
dataSource.searchEvents(query) 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> = override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =

View File

@@ -180,6 +180,8 @@ 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,
// Excerpted, not just filtered on: a hit has to show what it matched.
CalendarContract.Events.DESCRIPTION,
) )
const val IDX_ID = 0 const val IDX_ID = 0
@@ -194,6 +196,7 @@ internal object SearchProjection {
const val IDX_LOCATION = 9 const val IDX_LOCATION = 9
const val IDX_RRULE = 10 const val IDX_RRULE = 10
const val IDX_RDATE = 11 const val IDX_RDATE = 11
const val IDX_DESCRIPTION = 12
} }
/** /**

View File

@@ -40,5 +40,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0, isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
color = color, color = color,
location = getString(SearchProjection.IDX_LOCATION), location = getString(SearchProjection.IDX_LOCATION),
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
) )
} }

View File

@@ -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,
/** 112, 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
}

View File

@@ -62,6 +62,11 @@ data class EventInstance(
val isAllDay: Boolean, val isAllDay: Boolean,
val color: Int, val color: Int,
val location: String?, 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,
) )
/** /**

View File

@@ -1,67 +1,108 @@
package de.jeanlucmakiola.calendula.ui.search 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.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close 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.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.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource 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.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance 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.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position 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.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.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import kotlinx.coroutines.delay
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import java.time.Instant as JavaInstant import java.time.Instant as JavaInstant
@@ -69,11 +110,13 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle 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 * 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. A full-screen overlay hosted by * A full-screen overlay hosted by [de.jeanlucmakiola.calendula.ui.CalendarHost].
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -85,63 +128,79 @@ fun SearchScreen(
) { ) {
val query by viewModel.query.collectAsStateWithLifecycle() val query by viewModel.query.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val selection by viewModel.selection.collectAsStateWithLifecycle()
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current 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 // 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()
} }
// 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( Scaffold(
modifier = modifier.predictiveBack(onBack = onBack), modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
topBar = { topBar = {
TopAppBar( SearchTopBar(
title = { inSelection = inSelection,
InlineTextField( query = query,
value = query, selectedCount = selection.size,
onValueChange = viewModel::setQuery, focusRequester = focusRequester,
placeholder = stringResource(R.string.search_hint), onQueryChange = viewModel::setQuery,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Search,
onImeAction = { keyboard?.hide() }, onImeAction = { keyboard?.hide() },
modifier = Modifier onBack = onBack,
.fillMaxWidth() onCloseSelection = viewModel::clearSelection,
.focusRequester(focusRequester), onSelectAll = viewModel::selectAll,
) onDelete = { requireWrite { showDeleteDialog = true } },
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.search_back),
)
}
},
actions = {
if (query.isNotEmpty()) {
IconButton(onClick = { viewModel.setQuery("") }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.search_clear),
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) )
}, },
) { padding -> ) { 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()
@@ -158,52 +217,314 @@ fun SearchScreen(
text = stringResource(R.string.search_empty, s.query), text = stringResource(R.string.search_empty, s.query),
) )
is SearchUiState.Results -> SearchResults( is SearchUiState.Results -> SearchResults(
events = s.events, results = s,
selection = selection,
inSelection = inSelection,
onEventClick = onEventClick, onEventClick = onEventClick,
onToggle = viewModel::toggleSelection,
) )
} }
DeleteOutcomeChip(
deleteState = deleteState,
onConsume = viewModel::consumeDeleteResult,
modifier = Modifier.align(Alignment.BottomStart),
)
}
}
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 @Composable
private fun SearchResults( private fun SearchResults(
events: List<EventInstance>, results: SearchUiState.Results,
selection: Set<Long>,
inSelection: Boolean,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onToggle: (Long) -> Unit,
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp), // No horizontal inset: GroupedRow already insets itself.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) { ) {
itemsIndexed( results.results.months.forEach { month ->
items = events, stickyHeader(key = "month-${month.year}-${month.monthNumber}") {
key = { _, event -> event.eventId }, SearchSectionHeader(text = monthLabel(month))
) { index, event -> }
SearchResultRow( hitRows(month.hits, results, selection, inSelection, onEventClick, onToggle)
event = event, }
position = positionOf(index, events.size), // Held back from the calendar above: matched only in the notes.
modifier = animateItemMotion(), if (results.results.inDescriptions.isNotEmpty()) {
onClick = { onEventClick(event) }, 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 @Composable
private fun SearchResultRow( private fun SearchResultRow(
event: EventInstance, hit: SearchHit,
position: Position, position: Position,
modifier: Modifier = Modifier, 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 dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current 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( GroupedRow(
modifier = modifier, // Faded like a past event anywhere else in the app — search reaches back
title = event.title, // through the whole history.
summary = searchSummary(event), modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
title = marked(event.title, hit.titleSpans, highlight),
summary = searchSummary(hit, highlight),
position = position, position = position,
minHeight = 64.dp, minHeight = 64.dp,
selected = selected,
dimmed = inSelection && !selectable,
leading = { leading = {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -212,21 +533,97 @@ private fun SearchResultRow(
.background(eventAccent(event.color, dark, soften)), .background(eventAccent(event.color, dark, soften)),
) )
}, },
trailing = if (inSelection && selectable) {
{
Checkbox(
checked = selected,
onCheckedChange = null,
)
}
} else {
null
},
onClick = onClick, 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 @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 locale = currentLocale()
val zone = remember { ZoneId.systemDefault() } val zone = remember { ZoneId.systemDefault() }
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())
@@ -237,8 +634,23 @@ private fun searchSummary(event: EventInstance): String {
} else { } else {
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start) remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
} }
val base = "$dateText · $timeText" return buildAnnotatedString {
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base 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 @Composable

View File

@@ -6,6 +6,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.EventInstance 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.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@@ -14,12 +18,19 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn 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 kotlin.time.Clock
import javax.inject.Inject import javax.inject.Inject
@@ -33,8 +44,30 @@ sealed interface SearchUiState {
/** A query ran but matched nothing. */ /** A query ran but matched nothing. */
data class Empty(val query: String) : SearchUiState data class Empty(val query: String) : SearchUiState
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */ /** Matches by month — see [de.jeanlucmakiola.calendula.domain.EventSearch]. */
data class Results(val events: List<EventInstance>) : SearchUiState 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) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -47,17 +80,42 @@ class SearchViewModel @Inject constructor(
private val _query = MutableStateFlow("") private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow() val query: StateFlow<String> = _query.asStateFlow()
val state: StateFlow<SearchUiState> = _query /** Bumped after a delete so the same query re-runs against the changed provider. */
.debounce(250L) private val _reload = MutableStateFlow(0)
.map { it.trim() }
.distinctUntilChanged() 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 -> .mapLatest { q ->
if (q.length < MIN_QUERY_LENGTH) { if (q.length < MIN_QUERY_LENGTH) {
SearchUiState.Idle SearchUiState.Idle
} else { } else {
val results = repository.searchEvents(q) val now = Clock.System.now()
if (results.isEmpty()) SearchUiState.Empty(q) val zone = TimeZone.currentSystemDefault()
else SearchUiState.Results(sortNearestFirst(results)) 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) } .catch { emit(SearchUiState.Idle) }
@@ -65,13 +123,94 @@ class SearchViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
fun setQuery(value: String) { fun setQuery(value: String) {
if (value != _query.value) _selection.value = emptySet()
_query.value = value _query.value = value
} }
/** Soonest upcoming (and ongoing) first, then the most recent past. */ /** Toggle one result; the first pick is what turns selection mode on. */
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> { fun toggleSelection(eventId: Long) {
val now = Clock.System.now() val current = _selection.value
val (upcoming, past) = events.partition { it.end >= now } _selection.value = if (eventId in current) current - eventId else current + eventId
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
} }
/** 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()
} }

View File

@@ -318,6 +318,26 @@
<string name="search_idle_hint">Search your events by title, location or notes.</string> <string name="search_idle_hint">Search your events by title, location or notes.</string>
<string name="search_empty">No events match “%1$s”.</string> <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 --> <!-- Home-screen widgets -->
<string name="widget_agenda_title">Upcoming</string> <string name="widget_agenda_title">Upcoming</string>
<string name="widget_agenda_label">Calendula agenda</string> <string name="widget_agenda_label">Calendula agenda</string>

View File

@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -451,15 +452,15 @@ class CalendarRepositoryImplTest {
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L)) calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L))
searchResult = { searchResult = {
listOf( listOf(
makeEvent(10L, "Shown", calendarId = 1L), SearchCandidate(makeEvent(10L, "Shown", calendarId = 1L)),
makeEvent(11L, "Switched off", calendarId = 2L), SearchCandidate(makeEvent(11L, "Switched off", calendarId = 2L)),
makeEvent(12L, "Hidden", calendarId = 3L), SearchCandidate(makeEvent(12L, "Hidden", calendarId = 3L)),
) )
} }
} }
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined) 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 @Test

View File

@@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
@@ -19,7 +20,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var calendarsResult: List<CalendarSource> = emptyList() var calendarsResult: List<CalendarSource> = emptyList()
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() } var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var searchResult: (String) -> List<EventInstance> = { _ -> emptyList() } var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null } var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() } var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList() var exportableEventsResult: List<IcsEvent> = emptyList()
@@ -29,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
@@ -71,7 +76,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
} }
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> = override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis) instancesResult(beginMillis, endMillis)
override fun searchEvents(query: String): List<EventInstance> = searchResult(query) override fun searchEvents(query: String): List<SearchCandidate> = searchResult(query)
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? = override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? =
eventDetailResult(eventId) eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> = override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
@@ -196,7 +201,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
} }
override fun deleteEvent(eventId: Long) { override fun deleteEvent(eventId: Long) {
writeError?.let { throw it } writeError?.let { if (deletes++ >= deletesBeforeError) throw it }
deletedEventIds += eventId deletedEventIds += eventId
managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } } managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } }
} }

View File

@@ -16,7 +16,11 @@ class SearchMapperTest {
eventColor: Any? = null, eventColor: Any? = null,
calendarColor: Int = 0xFFAABBCC.toInt(), calendarColor: Int = 0xFFAABBCC.toInt(),
location: String? = null, location: String? = null,
rrule: String? = null,
rdate: String? = null,
): MapColumnReader = MapColumnReader( ): MapColumnReader = MapColumnReader(
SearchProjection.IDX_RRULE to rrule,
SearchProjection.IDX_RDATE to rdate,
SearchProjection.IDX_ID to id, SearchProjection.IDX_ID to id,
SearchProjection.IDX_CALENDAR_ID to calendarId, SearchProjection.IDX_CALENDAR_ID to calendarId,
SearchProjection.IDX_TITLE to title, SearchProjection.IDX_TITLE to title,
@@ -42,4 +46,16 @@ class SearchMapperTest {
fun `absent dtstart drops the search hit`() { fun `absent dtstart drops the search hit`() {
assertThat(searchReader(dtstart = null).toSearchResult()).isNull() 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()
}
} }

View File

@@ -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()
}
}

View File

@@ -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()
}
}