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.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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> =
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Submodule floret-kit updated: 71a4f371b6...ea860ed781
Reference in New Issue
Block a user