Compare commits
10 Commits
b64d04f8e3
...
v2.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93b9aa8f34 | ||
| ca826737ed | |||
| 5365cc67eb | |||
|
|
25e3640ba0 | ||
| 3f85bac74e | |||
| 5b83b9da73 | |||
| 3c8c555b94 | |||
| a6a6c0c714 | |||
|
|
66dbdc8913 | ||
|
|
6a125b7c73 |
66
CHANGELOG.md
66
CHANGELOG.md
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.19.0] — 2026-08-10
|
||||
|
||||
### Added
|
||||
- **Drag an event to reschedule it.** Press and hold an event in the month,
|
||||
week or day view and drop it on another time or day — the only way to move an
|
||||
appointment used to be opening it and editing its start and end by hand. The
|
||||
block follows your finger, the timeline scrolls when you carry it to an edge,
|
||||
and the event's length is preserved: a 90-minute meeting dropped on 09:00 ends
|
||||
at 10:30. In week and day view the drop snaps to 15-minute steps; in month
|
||||
view it moves the event to the day you dropped it on and leaves the time
|
||||
alone. A confirmation names the new slot and offers **Undo** wherever the move
|
||||
can be taken back in one step. Events you can't edit — read-only calendars, and
|
||||
birthdays and anniversaries mirrored from your contacts — can't be picked up.
|
||||
Rescheduling isn't pointer-only: a screen reader gets a **Move…** action on
|
||||
every event that can be moved ([#68]).
|
||||
- Dragging a **recurring** event asks what it should apply to, the same *this
|
||||
event / this and following / all events* choice a save does. Where moving the
|
||||
whole series would change which weekday it falls on, the rule is rewritten to
|
||||
match — a weekly Monday event dropped on a Wednesday becomes a weekly
|
||||
Wednesday event. Where it can't be rewritten safely, the prompt says so and
|
||||
offers to move just the one occurrence, rather than quietly leaving the
|
||||
series behind ([#68]).
|
||||
- **Delete several search results at once.** Long-press a hit in search to enter
|
||||
selection mode, tap the rest (or **Select all**), and delete the lot in one
|
||||
action — finding and removing, say, every leftover event with the same title
|
||||
no longer means opening them one at a time. Recurring hits in the batch ask
|
||||
once for the whole selection instead of once each, results on read-only
|
||||
calendars can't be selected, and the deletion is confirmed before it runs
|
||||
([#80]).
|
||||
|
||||
### Changed
|
||||
- **First launch is now a short wizard** instead of a single permission screen.
|
||||
It counts its steps, each one can be stepped back out of, and beyond granting
|
||||
calendar access it offers two things worth deciding once: turning on automatic
|
||||
backup — chosen inline, folder picker and all, since events that live only on
|
||||
the phone are lost with it — and choosing which view Calendula opens on, with
|
||||
a live preview of the month, week, day and agenda layouts rather than four
|
||||
names in a list. Both are skippable, everything chosen is changeable in
|
||||
Settings afterwards, and the backup step is offered only where nothing
|
||||
writable is being synced anywhere. Existing installs are not re-onboarded —
|
||||
revoking and re-granting the calendar permission later does not restart the
|
||||
wizard ([#163]).
|
||||
- The one-time notice about calendars switched off for this device is now a step
|
||||
in the flow rather than a dialog thrown over the app on first open.
|
||||
|
||||
### Fixed
|
||||
- Removed the deprecated `statusBarColor` / `navigationBarColor` theme
|
||||
attributes the Play Console flags on Android 15 and up, where they no longer
|
||||
do anything. The launch backdrop now picks its status-bar icons from the
|
||||
light/dark resource instead, so they stay legible over it either way
|
||||
([#123]).
|
||||
|
||||
## [2.18.1] — 2026-08-06
|
||||
|
||||
### Fixed
|
||||
- Tapping an empty slot in week or day view creates the event at the hour you
|
||||
tapped, after the timeline has been zoomed. Pinching the day taller or shorter
|
||||
— or changing **Hour height** in Settings — left the tap still being measured
|
||||
against the old spacing, so the new event landed at some other hour entirely
|
||||
([#148]).
|
||||
|
||||
## [2.18.0] — 2026-07-31
|
||||
|
||||
### Added
|
||||
@@ -1323,3 +1384,8 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#57]: https://codeberg.org/jlmakiola/calendula/issues/57
|
||||
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56
|
||||
[#114]: https://codeberg.org/jlmakiola/calendula/issues/114
|
||||
[#148]: https://codeberg.org/jlmakiola/calendula/issues/148
|
||||
[#68]: https://codeberg.org/jlmakiola/calendula/issues/68
|
||||
[#80]: https://codeberg.org/jlmakiola/calendula/issues/80
|
||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 21800
|
||||
versionName = "2.18.0"
|
||||
versionCode = 21900
|
||||
versionName = "2.19.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
@@ -27,8 +27,10 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
@@ -62,12 +64,14 @@ interface CalendarDataSource {
|
||||
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
||||
|
||||
/**
|
||||
* Master/one-off events whose title, description or location contains
|
||||
* [query] (case-insensitive), across all calendars, newest first. Reads the
|
||||
* Events table directly so the search is unbounded in time; exception rows
|
||||
* are excluded (see [SearchProjection]). [query] is assumed non-blank.
|
||||
* Master/one-off events that may match [query], across all calendars: every
|
||||
* whitespace-separated token has to appear in the title, description or
|
||||
* location. Reads the Events table directly so the search is unbounded in
|
||||
* time; exception rows are excluded (see [SearchProjection]). A deliberate
|
||||
* superset — [EventSearch] makes the final call and orders the hits.
|
||||
* [query] is assumed non-blank.
|
||||
*/
|
||||
fun searchEvents(query: String): List<EventInstance>
|
||||
fun searchEvents(query: String): List<SearchCandidate>
|
||||
|
||||
/**
|
||||
* The event-colour palette the calendar's account publishes
|
||||
@@ -585,20 +589,23 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||
}
|
||||
|
||||
override fun searchEvents(query: String): List<EventInstance> {
|
||||
override fun searchEvents(query: String): List<SearchCandidate> {
|
||||
ensureObserversRegistered()
|
||||
val trimmed = query.trim()
|
||||
if (trimmed.isEmpty()) return emptyList()
|
||||
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches
|
||||
// itself instead of acting as a wildcard.
|
||||
val escaped = trimmed
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
val like = "%$escaped%"
|
||||
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
||||
val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS)
|
||||
if (tokens.isEmpty()) return emptyList()
|
||||
// Only a pre-filter: EventSearch re-checks every token, including the
|
||||
// ones the cap above dropped.
|
||||
val patterns = tokens.map { likePatterns(it) }
|
||||
val match = patterns.joinToString(" AND ") { variants ->
|
||||
variants.joinToString(" OR ", prefix = "(", postfix = ")") {
|
||||
"${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
||||
}
|
||||
}
|
||||
val args = patterns
|
||||
.flatMap { variants -> variants.flatMap { pattern -> List(3) { pattern } } }
|
||||
.toTypedArray()
|
||||
val selection = "($match) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||
@@ -606,19 +613,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
SearchProjection.COLUMNS,
|
||||
selection,
|
||||
arrayOf(like, like, like),
|
||||
args,
|
||||
CalendarContract.Events.DTSTART + " DESC",
|
||||
)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
val out = ArrayList<EventInstance>(c.count)
|
||||
val out = ArrayList<SearchCandidate>(c.count)
|
||||
while (c.moveToNext()) {
|
||||
val description = reader.getString(SearchProjection.IDX_DESCRIPTION)
|
||||
val base = reader.toSearchResult() ?: continue
|
||||
// A recurring master's DTSTART is the series start; show its
|
||||
// nearest occurrence instead so the date is the one the user
|
||||
// actually cares about (and sorting reflects it).
|
||||
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
|
||||
out += if (recurring) {
|
||||
// A recurring master's DTSTART is the series start; date the row
|
||||
// by its nearest occurrence instead.
|
||||
val event = if (base.isRecurring) {
|
||||
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
||||
base.copy(
|
||||
start = begin.toKotlinInstantFromEpochMillis(),
|
||||
@@ -628,11 +633,44 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
} else {
|
||||
base
|
||||
}
|
||||
// The raw title, so matching can't catch on the placeholder an
|
||||
// untitled event is drawn with.
|
||||
out += SearchCandidate(
|
||||
event = event,
|
||||
description = description,
|
||||
title = reader.getString(SearchProjection.IDX_TITLE),
|
||||
)
|
||||
}
|
||||
out
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* One token as the `LIKE` patterns to OR together. SQLite folds case for
|
||||
* ASCII only, so a token with cased non-ASCII letters is also queried lower-,
|
||||
* upper- and title-cased, which is how "ärzte" reaches "Ärzte". Wildcarding
|
||||
* those letters away instead would make "москва" `%______%` — a full scan.
|
||||
*/
|
||||
private fun likePatterns(token: String): List<String> {
|
||||
if (token.none { it.code > 127 && (it.isUpperCase() || it.isLowerCase()) }) {
|
||||
return listOf(likePattern(token))
|
||||
}
|
||||
val lower = token.lowercase()
|
||||
return listOf(token, lower, token.uppercase(), lower.replaceFirstChar(Char::uppercaseChar))
|
||||
.distinct()
|
||||
.map(::likePattern)
|
||||
}
|
||||
|
||||
private fun likePattern(token: String): String {
|
||||
val sb = StringBuilder("%")
|
||||
for (c in token) {
|
||||
// A literal wildcard from the query matches itself.
|
||||
if (c == '%' || c == '_' || c == '\\') sb.append('\\')
|
||||
sb.append(c)
|
||||
}
|
||||
return sb.append('%').toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
||||
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
||||
@@ -1593,5 +1631,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
* next fires beyond it falls back to its series-start date.
|
||||
*/
|
||||
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Tokens the SQL pre-filter is built from. Bounds the statement for a
|
||||
* pasted paragraph; the ranker still requires every token, so a capped
|
||||
* search returns fewer rows to check, never more hits.
|
||||
*/
|
||||
const val MAX_SEARCH_TOKENS = 8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
@@ -17,11 +18,11 @@ interface CalendarRepository {
|
||||
suspend fun eventDetail(eventId: Long): EventDetail
|
||||
|
||||
/**
|
||||
* Events whose title, description or location contains [query], with hidden
|
||||
* calendars removed and newest first. Empty when [query] is blank. Searches
|
||||
* the whole history/future (see [CalendarDataSource.searchEvents]).
|
||||
* Candidate matches for [query] with hidden calendars removed; empty when
|
||||
* [query] is blank. Searches the whole history/future and leaves matching
|
||||
* and ranking to [de.jeanlucmakiola.calendula.domain.EventSearch].
|
||||
*/
|
||||
suspend fun searchEvents(query: String): List<EventInstance>
|
||||
suspend fun searchEvents(query: String): List<SearchCandidate>
|
||||
|
||||
/**
|
||||
* The event-colour palette a calendar's account publishes; empty when it
|
||||
|
||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -162,13 +163,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
?: throw NoSuchEventException(eventId)
|
||||
}
|
||||
|
||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
|
||||
if (query.isBlank()) return@withContext emptyList()
|
||||
val excluded = prefs.hiddenCalendarIds.first() +
|
||||
prefs.pendingDisabledCalendarIds.first() +
|
||||
invisibleCalendarIds()
|
||||
dataSource.searchEvents(query)
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
|
||||
}
|
||||
|
||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
|
||||
@@ -180,6 +180,8 @@ internal object SearchProjection {
|
||||
// display its nearest occurrence, not the series-start DTSTART.
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.RDATE,
|
||||
// Excerpted, not just filtered on: a hit has to show what it matched.
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
@@ -194,6 +196,7 @@ internal object SearchProjection {
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_RRULE = 10
|
||||
const val IDX_RDATE = 11
|
||||
const val IDX_DESCRIPTION = 12
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,5 +40,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
||||
color = color,
|
||||
location = getString(SearchProjection.IDX_LOCATION),
|
||||
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -550,8 +550,87 @@ class SettingsPrefs @Inject constructor(
|
||||
prefs[REMINDER_ONBOARDING_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setReminderOnboardingDone() {
|
||||
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
||||
/** Cleared, not just set: the wizard's back button re-asks a step (#163). */
|
||||
suspend fun setReminderOnboardingDone(done: Boolean = true) {
|
||||
store.edit { it[REMINDER_ONBOARDING_KEY] = done }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this install is a fresh one still owing the wizard's optional
|
||||
* steps (#163). Armed on any launch that holds the calendar permission and
|
||||
* has not finished the reminder step, and cleared again by
|
||||
* [finishOnboardingWizard], so an existing user who revokes and re-grants
|
||||
* the permission isn't re-onboarded.
|
||||
*/
|
||||
val onboardingWizardArmed: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_WIZARD_ARMED_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun armOnboardingWizard() {
|
||||
store.edit { prefs ->
|
||||
if (prefs[REMINDER_ONBOARDING_KEY] != true) prefs[ONBOARDING_WIZARD_ARMED_KEY] = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the wizard's backup step has been answered (or skipped). */
|
||||
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingBackupDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_BACKUP_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's default-view step has been answered (or skipped). */
|
||||
val onboardingViewDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_VIEW_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingViewDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_VIEW_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's month-style step has been answered (or skipped). */
|
||||
val onboardingMonthStyleDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_MONTH_STYLE_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingMonthStyleDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_MONTH_STYLE_KEY] = done }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the wizard's calendar-visibility notice has been read. Separate
|
||||
* from [CalendarPrefs.visibilityNoticePending], which says whether this
|
||||
* install has anything to announce at all.
|
||||
*/
|
||||
val onboardingVisibilityDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_VISIBILITY_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingVisibilityDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_VISIBILITY_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's closing screen has been seen. */
|
||||
val onboardingDoneShown: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_DONE_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingDoneShown(shown: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_DONE_KEY] = shown }
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the wizard for good: the closing screen has been seen and the
|
||||
* install stops counting as fresh, so a later revoke-and-re-grant is only
|
||||
* the permission screen. One edit, so the plan can't see it half-closed.
|
||||
*/
|
||||
suspend fun finishOnboardingWizard() {
|
||||
store.edit { prefs ->
|
||||
prefs[ONBOARDING_DONE_KEY] = true
|
||||
prefs[ONBOARDING_WIZARD_ARMED_KEY] = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -882,6 +961,14 @@ class SettingsPrefs @Inject constructor(
|
||||
internal const val MAX_EVENT_DURATION = 1_440
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
||||
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
||||
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||
internal val ONBOARDING_MONTH_STYLE_KEY =
|
||||
booleanPreferencesKey("onboarding_month_style_done")
|
||||
internal val ONBOARDING_VISIBILITY_KEY =
|
||||
booleanPreferencesKey("onboarding_visibility_done")
|
||||
internal val ONBOARDING_DONE_KEY = booleanPreferencesKey("onboarding_done_shown")
|
||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** An event with the free text the search may have matched it on. */
|
||||
data class SearchCandidate(
|
||||
val event: EventInstance,
|
||||
val description: String? = null,
|
||||
/** Null when the event has none: [EventInstance.title] is then a display
|
||||
* placeholder, which the query must not catch on. */
|
||||
val title: String? = event.title,
|
||||
)
|
||||
|
||||
/** A matched run inside one of a hit's texts, for highlighting. */
|
||||
data class MatchSpan(val start: Int, val end: Int)
|
||||
|
||||
/** The part of a description a token matched, elided to what fits a row. */
|
||||
data class DescriptionSnippet(
|
||||
val text: String,
|
||||
val spans: List<MatchSpan>,
|
||||
)
|
||||
|
||||
/** One result: the event, plus where the query matched it. */
|
||||
data class SearchHit(
|
||||
val event: EventInstance,
|
||||
val titleSpans: List<MatchSpan> = emptyList(),
|
||||
val locationSpans: List<MatchSpan> = emptyList(),
|
||||
val descriptionSnippet: DescriptionSnippet? = null,
|
||||
/** Already over, so the row can say so. Finished-today counts. */
|
||||
val isPast: Boolean = false,
|
||||
)
|
||||
|
||||
/** One month's worth of hits, as the results list draws them under a header. */
|
||||
data class SearchMonth(
|
||||
val year: Int,
|
||||
/** 1–12, so the UI can build the month's first day for a localized label. */
|
||||
val monthNumber: Int,
|
||||
val hits: List<SearchHit>,
|
||||
)
|
||||
|
||||
/** A finished search: a calendar of hits, plus the description-only ones. */
|
||||
data class SearchResults(
|
||||
val months: List<SearchMonth>,
|
||||
val inDescriptions: List<SearchHit>,
|
||||
) {
|
||||
val isEmpty: Boolean get() = months.isEmpty() && inDescriptions.isEmpty()
|
||||
|
||||
/** Every hit, whichever section it sits in — for select-all and delete. */
|
||||
val allHits: List<SearchHit> get() = months.flatMap { it.hits } + inDescriptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Turning a typed query into the results list.
|
||||
*
|
||||
* An event has to carry *every* whitespace-separated token, each in any of
|
||||
* title / location / description; case is folded in Kotlin rather than by SQL's
|
||||
* ASCII-only `LIKE`, so "ärzte" finds "Ärzte".
|
||||
*
|
||||
* Results are a calendar, not a ranking: months from the current one, then
|
||||
* forwards, then backwards, and inside a month what is still to come before
|
||||
* what has passed. The day is the boundary, not the moment. A hit the query
|
||||
* only reached through its description is held back in
|
||||
* [SearchResults.inDescriptions] rather than dated among the real ones.
|
||||
*/
|
||||
object EventSearch {
|
||||
|
||||
/** Characters of description kept around the match in a row's snippet. */
|
||||
private const val SNIPPET_LENGTH = 96
|
||||
|
||||
/** Characters of lead-in shown before the match, when there is room. */
|
||||
private const val SNIPPET_LEAD = 24
|
||||
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
|
||||
/** Shared with the data layer so its SQL pre-filter tokenises identically. */
|
||||
fun tokenize(query: String): List<String> =
|
||||
query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Search [candidates] for [query], dropping any that don't carry every token.
|
||||
* [now] marks a hit as over, [todayStart] is where it stops being current.
|
||||
* [zone] places each event on the calendar through the span rule, so an
|
||||
* all-day event keeps its own day whichever side of UTC we are on (#82).
|
||||
*/
|
||||
fun search(
|
||||
candidates: List<SearchCandidate>,
|
||||
query: String,
|
||||
now: Instant,
|
||||
todayStart: Instant,
|
||||
zone: TimeZone,
|
||||
): SearchResults {
|
||||
val tokens = tokenize(query)
|
||||
if (tokens.isEmpty()) return SearchResults(emptyList(), emptyList())
|
||||
|
||||
val today = todayStart.toLocalDateTime(zone).date
|
||||
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
|
||||
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today }
|
||||
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 }
|
||||
.thenComparator { a, b ->
|
||||
// What is left reads forwards; what is behind us reads backwards
|
||||
// from today, nearest first.
|
||||
if (upcoming(a)) {
|
||||
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
|
||||
} else {
|
||||
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
|
||||
}
|
||||
}
|
||||
|
||||
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
|
||||
return SearchResults(
|
||||
months = groupByMonth(dated, zone, todayStart),
|
||||
inDescriptions = inDescriptions.map { it.hit },
|
||||
)
|
||||
}
|
||||
|
||||
/** Months from the current one, then forwards, then backwards. */
|
||||
private fun groupByMonth(
|
||||
dated: List<Matched>,
|
||||
zone: TimeZone,
|
||||
todayStart: Instant,
|
||||
): List<SearchMonth> {
|
||||
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
|
||||
return dated
|
||||
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
|
||||
.entries
|
||||
.sortedWith(
|
||||
compareBy<Map.Entry<Int, List<Matched>>> {
|
||||
when {
|
||||
it.key == currentKey -> 0
|
||||
it.key > currentKey -> 1
|
||||
else -> 2
|
||||
}
|
||||
}.thenComparator { a, b ->
|
||||
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
|
||||
},
|
||||
)
|
||||
.map { (key, entries) ->
|
||||
SearchMonth(
|
||||
year = key / 12,
|
||||
monthNumber = key % 12 + 1,
|
||||
hits = entries.map { it.hit },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Year and month as one comparable number. */
|
||||
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
|
||||
|
||||
private fun match(
|
||||
candidate: SearchCandidate,
|
||||
tokens: List<String>,
|
||||
now: Instant,
|
||||
zone: TimeZone,
|
||||
): Matched? {
|
||||
val title = candidate.title?.takeIf { it.isNotBlank() }
|
||||
val location = candidate.event.location?.takeIf { it.isNotBlank() }
|
||||
// Collapsed so a multi-line description matches the way it is drawn.
|
||||
val description = candidate.description
|
||||
?.replace(WHITESPACE, " ")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
|
||||
val present = { token: String ->
|
||||
title?.contains(token, ignoreCase = true) == true ||
|
||||
location?.contains(token, ignoreCase = true) == true ||
|
||||
description?.contains(token, ignoreCase = true) == true
|
||||
}
|
||||
if (!tokens.all(present)) return null
|
||||
|
||||
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
|
||||
val titleSpans = title?.let { spansIn(it, tokens) }.orEmpty()
|
||||
val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty()
|
||||
return Matched(
|
||||
hit = SearchHit(
|
||||
event = candidate.event,
|
||||
titleSpans = titleSpans,
|
||||
locationSpans = locationSpans,
|
||||
descriptionSnippet = description
|
||||
?.takeIf { descriptionSpans.isNotEmpty() }
|
||||
?.let { snippet(it, descriptionSpans) },
|
||||
isPast = candidate.event.dayEnd(zone) < now,
|
||||
),
|
||||
// Nothing in the name or the place caught the query: it is only here
|
||||
// for something in its notes.
|
||||
descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Every occurrence of every token in [text], merged where they overlap. */
|
||||
private fun spansIn(text: String, tokens: List<String>): List<MatchSpan> {
|
||||
val raw = mutableListOf<MatchSpan>()
|
||||
for (token in tokens) {
|
||||
var index = text.indexOf(token, startIndex = 0, ignoreCase = true)
|
||||
while (index >= 0) {
|
||||
raw += MatchSpan(index, index + token.length)
|
||||
index = text.indexOf(token, startIndex = index + 1, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
if (raw.isEmpty()) return emptyList()
|
||||
val sorted = raw.sortedBy { it.start }
|
||||
val merged = mutableListOf(sorted.first())
|
||||
for (span in sorted.drop(1)) {
|
||||
val last = merged.last()
|
||||
if (span.start <= last.end) {
|
||||
merged[merged.lastIndex] = MatchSpan(last.start, maxOf(last.end, span.end))
|
||||
} else {
|
||||
merged += span
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* A window of [description] around its first match, elided at both ends,
|
||||
* with the spans rebased onto it and any falling outside dropped.
|
||||
*/
|
||||
private fun snippet(description: String, spans: List<MatchSpan>): DescriptionSnippet {
|
||||
if (description.length <= SNIPPET_LENGTH) {
|
||||
return DescriptionSnippet(description, spans)
|
||||
}
|
||||
val first = spans.first().start
|
||||
val start = (first - SNIPPET_LEAD).coerceIn(0, (description.length - SNIPPET_LENGTH))
|
||||
val end = (start + SNIPPET_LENGTH).coerceAtMost(description.length)
|
||||
val prefix = if (start > 0) "…" else ""
|
||||
val suffix = if (end < description.length) "…" else ""
|
||||
val text = prefix + description.substring(start, end) + suffix
|
||||
val shift = prefix.length - start
|
||||
val rebased = spans
|
||||
.filter { it.start >= start && it.end <= end }
|
||||
.map { MatchSpan(it.start + shift, it.end + shift) }
|
||||
return DescriptionSnippet(text, rebased)
|
||||
}
|
||||
|
||||
private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) {
|
||||
val event: EventInstance get() = hit.event
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's first day as an instant in [zone]. An all-day event sits at
|
||||
* UTC midnight, so its raw start would rank it on a day it isn't drawn on.
|
||||
*/
|
||||
private fun EventInstance.dayStart(zone: TimeZone): Instant =
|
||||
if (isAllDay) spanFirstDay(zone).atStartOfDayIn(zone) else start
|
||||
|
||||
/** The moment the event's last day is over in [zone], exclusive. */
|
||||
private fun EventInstance.dayEnd(zone: TimeZone): Instant =
|
||||
if (isAllDay) spanLastDay(zone).plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) else end
|
||||
}
|
||||
@@ -62,6 +62,11 @@ data class EventInstance(
|
||||
val isAllDay: Boolean,
|
||||
val color: Int,
|
||||
val location: String?,
|
||||
/**
|
||||
* Only search results, which read the series master, fill this in — the
|
||||
* Instances query already yields one row per occurrence.
|
||||
*/
|
||||
val isRecurring: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,12 +2,15 @@ package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -22,13 +25,22 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
|
||||
/** What the root is showing: a wizard step, the app, or neither yet. */
|
||||
private sealed interface RootTarget {
|
||||
/** DataStore has not emitted yet — render nothing rather than the wrong screen. */
|
||||
data object Loading : RootTarget
|
||||
data object App : RootTarget
|
||||
data class Step(val step: OnboardingStep) : RootTarget
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun RootScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -76,58 +88,87 @@ fun RootScreen(
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
|
||||
// Cross-fade the one-time onboarding gates so granting permission / finishing
|
||||
// onboarding eases into the next screen instead of snapping. A fade carries no
|
||||
// spatial motion, so it stays appropriate under "remove animations" too.
|
||||
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
|
||||
if (granted) {
|
||||
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||
// Null until DataStore's first emission — render nothing for that
|
||||
// frame instead of flashing the wrong screen.
|
||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||
// One-time explainer for the switch to the device's own calendar
|
||||
// visibility (#75), armed by the reconciler.
|
||||
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
|
||||
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
|
||||
// Runs on entry however the permission was granted, including via
|
||||
// Android's app-settings screen (caught by the ON_RESUME above).
|
||||
LaunchedEffect(Unit) {
|
||||
visibilityNotice.reconcile()
|
||||
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
|
||||
val onboarding: OnboardingViewModel = hiltViewModel()
|
||||
val plan by onboarding.plan.collectAsStateWithLifecycle()
|
||||
// The grant is reported however it was taken, including via Android's
|
||||
// app-settings screen (caught by the ON_RESUME above); arming the wizard
|
||||
// hangs off that, not off the in-app callback.
|
||||
LaunchedEffect(hasPermission) {
|
||||
onboarding.setHasPermission(hasPermission)
|
||||
if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted()
|
||||
}
|
||||
|
||||
// Arms the one-time explainer for the switch to the device's own calendar
|
||||
// visibility (#75), which the wizard then carries as a step of its own.
|
||||
LaunchedEffect(hasPermission) {
|
||||
if (hasPermission) onboarding.reconcileCalendarVisibility()
|
||||
}
|
||||
|
||||
val current = plan
|
||||
val target: RootTarget = when {
|
||||
current == null -> RootTarget.Loading
|
||||
current.current == null -> RootTarget.App
|
||||
else -> RootTarget.Step(current.current)
|
||||
}
|
||||
|
||||
// Which way the flow is moving, so a step-back slides back. The app sorts
|
||||
// after every step, so finishing the wizard reads as one more move forward.
|
||||
val ordinal = when (target) {
|
||||
RootTarget.Loading -> Int.MIN_VALUE
|
||||
RootTarget.App -> Int.MAX_VALUE
|
||||
is RootTarget.Step -> current?.steps?.indexOf(target.step) ?: 0
|
||||
}
|
||||
var lastOrdinal by remember { mutableIntStateOf(ordinal) }
|
||||
val slideDir = if (ordinal < lastOrdinal) -1 else 1
|
||||
SideEffect { lastOrdinal = ordinal }
|
||||
|
||||
val slideSpec = rememberOnboardingSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
AnimatedContent(
|
||||
targetState = target,
|
||||
transitionSpec = {
|
||||
// Coming off the blank first frame is not a step change.
|
||||
if (initialState == RootTarget.Loading) {
|
||||
fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||
} else {
|
||||
onboardingSlideTransition(
|
||||
slideDir = slideDir,
|
||||
spec = slideSpec,
|
||||
fadeSpec = fadeSpec,
|
||||
reduceMotion = reduceMotion,
|
||||
)
|
||||
}
|
||||
if (onboardingDone == true && noticePending) {
|
||||
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
||||
}
|
||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||
when (done) {
|
||||
true -> CalendarHost(
|
||||
modifier = modifier,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||
widgetNavRequest = widgetNavRequest,
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
requestedInsertSource = requestedInsertSource,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
modifier = modifier,
|
||||
)
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PermissionScreen(
|
||||
onGranted = { hasPermission = true },
|
||||
},
|
||||
label = "onboardingGate",
|
||||
) { shown ->
|
||||
when (shown) {
|
||||
RootTarget.Loading -> Unit
|
||||
RootTarget.App -> CalendarHost(
|
||||
modifier = modifier,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||
widgetNavRequest = widgetNavRequest,
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
requestedInsertSource = requestedInsertSource,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
)
|
||||
// Both halves of a transition read their position out of the live
|
||||
// plan; the one the outgoing step was drawn with is already gone.
|
||||
is RootTarget.Step -> current?.let { plan ->
|
||||
OnboardingSteps(
|
||||
step = shown.step,
|
||||
plan = plan,
|
||||
viewModel = onboarding,
|
||||
onPermissionGranted = { hasPermission = true },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ private fun AgendaRangeBanner(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaContent(
|
||||
internal fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
pastDisplay: PastEventDisplay,
|
||||
showToday: Boolean,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/** How far ahead the agenda preview's stand-in window runs. */
|
||||
private const val PREVIEW_WINDOW_DAYS = 14
|
||||
|
||||
/**
|
||||
* A live, scaled-down Agenda view for the onboarding view chooser. Today and
|
||||
* the day headers are real; only the events are stand-ins.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgendaViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val state = remember(today, zone) { sampleAgendaState(today, zone) }
|
||||
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
AgendaContent(
|
||||
state = state,
|
||||
// Stand-in events are all upcoming, so the past-event preference
|
||||
// cannot change what the preview shows — keep it at the default.
|
||||
pastDisplay = PastEventDisplay.SHOW,
|
||||
showToday = true,
|
||||
onRetry = {},
|
||||
onEventClick = {},
|
||||
onOpenDay = {},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample agenda state, grouped through the same helper the live view uses. */
|
||||
private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.Success {
|
||||
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
|
||||
return AgendaUiState.Success(
|
||||
anchor = today,
|
||||
today = today,
|
||||
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
|
||||
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
|
||||
rangeIsOverride = false,
|
||||
rangeEnd = windowEnd,
|
||||
// The range bar belongs to the screen, not the list — the preview shows
|
||||
// the list only.
|
||||
showRangeBar = false,
|
||||
zone = zone,
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The one-time notice that Calendula now follows the device's per-calendar
|
||||
* visibility (#75), armed by `CalendarVisibilityReconciler`. The app does not
|
||||
* switch those calendars back on — that would un-hide them everywhere else too.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CalendarVisibilityNoticeViewModel @Inject constructor(
|
||||
private val prefs: CalendarPrefs,
|
||||
private val reconciler: CalendarVisibilityReconciler,
|
||||
) : ViewModel() {
|
||||
|
||||
/**
|
||||
* Reconcile whenever the app comes up with the calendar permission held,
|
||||
* rather than off one grant route: a permission granted on Android's
|
||||
* app-settings screen never reaches the permission screen's callback.
|
||||
*/
|
||||
fun reconcile() {
|
||||
viewModelScope.launch { reconciler.run() }
|
||||
}
|
||||
|
||||
val pending: StateFlow<Boolean> = prefs.visibilityNoticePending
|
||||
.map { it == true }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
fun dismiss() {
|
||||
viewModelScope.launch { prefs.setVisibilityNoticePending(false) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain informational dialog — one acknowledgement, nothing to decide. */
|
||||
@Composable
|
||||
fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) },
|
||||
title = { Text(stringResource(R.string.calendars_visibility_notice_title)) },
|
||||
text = { Text(stringResource(R.string.calendars_visibility_notice_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_ok))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Stand-in events for the view previews. A preview has no business querying the
|
||||
* provider for a thumbnail, so every chooser renders the same invented week.
|
||||
*
|
||||
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||
* event, so a theme token here would misrepresent what the views actually draw.
|
||||
*/
|
||||
private val SAMPLE_COLORS = listOf(
|
||||
0xFF3F7BD4.toInt(),
|
||||
0xFFCE5B4C.toInt(),
|
||||
0xFF4E9A6A.toInt(),
|
||||
0xFF8A63C7.toInt(),
|
||||
)
|
||||
|
||||
private val SAMPLE_TITLES = listOf(
|
||||
"Standup",
|
||||
"Lunch",
|
||||
"Review",
|
||||
"Gym",
|
||||
"Call",
|
||||
"Workshop",
|
||||
"Dentist",
|
||||
"Trip",
|
||||
)
|
||||
|
||||
/** Hands out [EventInstance]s with running ids and cycling sample titles/colours. */
|
||||
private class SampleEvents(private val zone: TimeZone) {
|
||||
private var id = 0L
|
||||
|
||||
fun timed(
|
||||
day: LocalDate,
|
||||
hour: Int,
|
||||
minute: Int = 0,
|
||||
lengthMinutes: Int,
|
||||
colorIndex: Int,
|
||||
): EventInstance {
|
||||
val start = day.atTime(hour, minute).toInstant(zone)
|
||||
return build(
|
||||
start = start,
|
||||
end = start + lengthMinutes.minutes,
|
||||
isAllDay = false,
|
||||
colorIndex = colorIndex,
|
||||
)
|
||||
}
|
||||
|
||||
fun allDay(from: LocalDate, days: Int, colorIndex: Int): EventInstance = build(
|
||||
// All-day events sit at UTC midnights with an exclusive end.
|
||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
isAllDay = true,
|
||||
colorIndex = colorIndex,
|
||||
)
|
||||
|
||||
private fun build(
|
||||
start: Instant,
|
||||
end: Instant,
|
||||
isAllDay: Boolean,
|
||||
colorIndex: Int,
|
||||
): EventInstance {
|
||||
val next = ++id
|
||||
return EventInstance(
|
||||
instanceId = next,
|
||||
eventId = next,
|
||||
calendarId = 1L,
|
||||
title = SAMPLE_TITLES[(next.toInt() - 1) % SAMPLE_TITLES.size],
|
||||
start = start,
|
||||
end = end,
|
||||
isAllDay = isAllDay,
|
||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||
location = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A month's worth of stand-ins, spread so most weeks carry something, with one
|
||||
* multi-day bar bridging cells and a busy today for the split pane.
|
||||
*/
|
||||
internal fun sampleMonthEvents(
|
||||
firstOfMonth: LocalDate,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
): List<EventInstance> {
|
||||
val sample = SampleEvents(zone)
|
||||
return buildList {
|
||||
add(sample.allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
||||
add(sample.timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, lengthMinutes = 120, colorIndex = 1))
|
||||
add(sample.timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
|
||||
add(sample.timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, lengthMinutes = 60, colorIndex = 2))
|
||||
add(sample.timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, lengthMinutes = 120, colorIndex = 1))
|
||||
add(sample.timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, lengthMinutes = 60, colorIndex = 3))
|
||||
// Today, so the split pane's list and the grid's dots both have content.
|
||||
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(today, 12, lengthMinutes = 60, colorIndex = 1))
|
||||
add(sample.timed(today, 15, lengthMinutes = 120, colorIndex = 3))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-ins for a timeline preview over [days]. [anchor] — the day the preview
|
||||
* is centred on — gets the busy set, including one overlapping pair so the
|
||||
* side-by-side lane layout shows; the rest of the week is sparser. A multi-day
|
||||
* all-day bar is only added when there is more than one column to span.
|
||||
*/
|
||||
internal fun sampleTimelineEvents(
|
||||
days: List<LocalDate>,
|
||||
anchor: LocalDate,
|
||||
zone: TimeZone,
|
||||
): List<EventInstance> {
|
||||
val sample = SampleEvents(zone)
|
||||
return buildList {
|
||||
if (days.size > 1) {
|
||||
add(sample.allDay(days[days.size / 2], days = 2, colorIndex = 2))
|
||||
} else {
|
||||
add(sample.allDay(anchor, days = 1, colorIndex = 2))
|
||||
}
|
||||
// The anchor day: enough to fill the column, with 09:30–11:00 running
|
||||
// under 09:00–10:00 so the overlap resolves into two lanes.
|
||||
add(sample.timed(anchor, 9, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(anchor, 9, minute = 30, lengthMinutes = 90, colorIndex = 1))
|
||||
add(sample.timed(anchor, 13, lengthMinutes = 120, colorIndex = 3))
|
||||
days.filterNot { it == anchor }.forEachIndexed { index, day ->
|
||||
// Skip every third day so the week doesn't read as a solid block.
|
||||
if (index % 3 == 2) return@forEachIndexed
|
||||
add(
|
||||
sample.timed(
|
||||
day = day,
|
||||
hour = 8 + (index * 3) % 9,
|
||||
lengthMinutes = if (index % 2 == 0) 60 else 90,
|
||||
colorIndex = index,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-ins for the agenda preview: a forward-looking fortnight where the first
|
||||
* days are busy and later ones thin out, so the grouped-by-day list shows both
|
||||
* a full day and the gaps between days.
|
||||
*/
|
||||
internal fun sampleAgendaEvents(today: LocalDate, zone: TimeZone): List<EventInstance> {
|
||||
val sample = SampleEvents(zone)
|
||||
return buildList {
|
||||
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(today, 12, minute = 30, lengthMinutes = 60, colorIndex = 1))
|
||||
add(sample.timed(today, 16, lengthMinutes = 90, colorIndex = 3))
|
||||
val tomorrow = today.plus(1, DateTimeUnit.DAY)
|
||||
add(sample.allDay(tomorrow, days = 2, colorIndex = 2))
|
||||
add(sample.timed(tomorrow, 10, lengthMinutes = 120, colorIndex = 1))
|
||||
add(sample.timed(today.plus(3, DateTimeUnit.DAY), 14, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(today.plus(6, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
|
||||
add(sample.timed(today.plus(10, DateTimeUnit.DAY), 18, lengthMinutes = 120, colorIndex = 2))
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,18 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
|
||||
.coerceAtLeast(fillHourHeight(viewportHeight))
|
||||
}
|
||||
|
||||
/**
|
||||
* The minute of the day a tap [offsetY] px down a timeline column means, snapped
|
||||
* to the hour it landed in.
|
||||
*
|
||||
* [hourPx] must be the hour height the column is drawing at *now* — a tap
|
||||
* detector that captured it when it was installed maps taps to a pre-pinch grid
|
||||
* (#148). A zero or negative height (a viewport measured at nothing) has no grid
|
||||
* to read, so it answers midnight rather than dividing by it.
|
||||
*/
|
||||
fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
|
||||
if (hourPx <= 0f) 0 else (offsetY / hourPx).toInt().coerceIn(0, 23) * 60
|
||||
|
||||
/**
|
||||
* Shortest an event block may render, as a fraction of an hour. Blocks keep a
|
||||
* floor so a 15-minute event stays tappable, but the floor scales with the hour
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The viewport a view preview pretends to be: a phone's width by the height a
|
||||
* calendar view gets under the top bar. Scaling every preview from one fixed
|
||||
* height keeps them comparable — each shows the same slice of screen.
|
||||
*/
|
||||
internal val PREVIEW_VIEWPORT_HEIGHT = 440.dp
|
||||
|
||||
/**
|
||||
* Renders [content] at a full phone viewport and shrinks the finished layout
|
||||
* into a box [height] tall, for the settings and onboarding choosers. Callers
|
||||
* pass the *real* view composables, so a preview cannot drift from what it
|
||||
* depicts.
|
||||
*
|
||||
* The [layout] below ignores the incoming constraints so the view measures at a
|
||||
* phone viewport, and a [graphicsLayer] scale shrinks the result.
|
||||
* `Modifier.requiredSize` is the obvious alternative but *centres* content that
|
||||
* overflows its constraints, leaving only the bottom-right corner in the clip.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ScaledViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val scale = height.value / PREVIEW_VIEWPORT_HEIGHT.value
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
// A preview is a picture, not a control: swallow touches before the
|
||||
// view's own clickables see them, and give screen readers one label
|
||||
// instead of six weeks of day cells.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics { }
|
||||
// Measure at a full phone viewport but report the scaled size, so the
|
||||
// node occupies exactly what it draws.
|
||||
.layout { measurable, _ ->
|
||||
val fullWidth = screenWidth.roundToPx()
|
||||
val fullHeight = PREVIEW_VIEWPORT_HEIGHT.roundToPx()
|
||||
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
},
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage every preview picker sets its preview on, cross-fading as [selected]
|
||||
* changes. A preview draws the app's own surface, so a tonal plate gives it the
|
||||
* edge it would otherwise lack on a chooser screen; the plate takes the width
|
||||
* and outer radius of the grouped rows underneath so the two read as one stack.
|
||||
*
|
||||
* Only vertical spacing is the caller's to pass — the horizontal inset is the
|
||||
* rows' own, and the alignment is the point.
|
||||
*/
|
||||
@Composable
|
||||
internal fun <T> ViewPreviewFrame(
|
||||
selected: T,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.clip(FRAME_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(FRAME_INSET),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = selected,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = label,
|
||||
) { shown ->
|
||||
// Each preview renders the real view at phone size and scales it
|
||||
// down, so its own corners are square — the plate rounds them.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PREVIEW_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
content(shown)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val FRAME_INSET = 8.dp
|
||||
|
||||
/** The grouped rows' own outer radius, so the stack shares one silhouette. */
|
||||
private val FRAME_SHAPE = RoundedCornerShape(22.dp)
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -117,6 +118,7 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -131,7 +133,7 @@ private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
|
||||
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
||||
private fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||
internal fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||
if (allDay.isEmpty()) return 0.dp
|
||||
val lanes = allDay.maxOf { it.lane } + 1
|
||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||
@@ -366,7 +368,7 @@ private fun DayContent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DaySuccess(
|
||||
internal fun DaySuccess(
|
||||
state: DayUiState.Success,
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
@@ -623,6 +625,12 @@ private fun DayColumnCard(
|
||||
LaunchedEffect(blocks, dragController.settling) {
|
||||
dragController.noteGrid(date, blocks)
|
||||
}
|
||||
// The tap detector outlives the composition that installed it — a pinch or a
|
||||
// Settings change moves the hour height without restarting it — so it reads
|
||||
// the height and the callback through state handles instead of capturing
|
||||
// them, or taps land on the scale the column had before the zoom (#148).
|
||||
val currentHourPx = rememberUpdatedState(hourPx)
|
||||
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||
Card(
|
||||
// Plain rectangular column — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -643,8 +651,10 @@ private fun DayColumnCard(
|
||||
// only fires on the column background. Snaps to the tapped hour.
|
||||
.pointerInput(date) {
|
||||
detectTapGestures { offset ->
|
||||
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
||||
onCreateAt(date, hour * 60)
|
||||
currentOnCreateAt.value(
|
||||
date,
|
||||
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* A live, scaled-down Day view for the onboarding view chooser. Today's date is
|
||||
* real; only the events are stand-ins.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DayViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val state = remember(today, zone) { sampleDayState(today, zone) }
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
// Half the scroll range is noon — the same centring the live view does.
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
DaySuccess(
|
||||
state = state,
|
||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = state.allDayStripHeight(),
|
||||
dragController = rememberTimelineDragController(),
|
||||
onEventClick = {},
|
||||
onCreateAt = { _, _ -> },
|
||||
onDrop = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample day state, laid out through the same helpers the live view uses. */
|
||||
private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success {
|
||||
val days = listOf(today)
|
||||
val events = sampleTimelineEvents(days, today, zone)
|
||||
return DayUiState.Success(
|
||||
date = today,
|
||||
today = today,
|
||||
allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
||||
timed = layoutDay(events, today, zone),
|
||||
)
|
||||
}
|
||||
@@ -1,54 +1,31 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleMonthEvents
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
||||
* chooser.
|
||||
*
|
||||
* It renders the *real* grid composables rather than a drawing of them, so the
|
||||
* preview cannot drift from the thing it depicts: change a cell's shape or an
|
||||
* event bar's colour and every preview follows automatically. The trick is
|
||||
* [requiredSize] — it ignores the incoming constraints, so the grid lays itself
|
||||
* out at a full phone width and a plausible viewport height, and a
|
||||
* [graphicsLayer] scale shrinks the finished layout into the card.
|
||||
* and onboarding choosers.
|
||||
*
|
||||
* The month, today's position and the week start are all real; only the events
|
||||
* are stand-ins, since the settings screen has no business querying the
|
||||
* provider for a thumbnail.
|
||||
* are stand-ins. See [ScaledViewPreview] for how the real grid composables get
|
||||
* measured at phone size and shrunk into the card.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MonthStylePreview(
|
||||
@@ -57,108 +34,61 @@ internal fun MonthStylePreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val scale = height.value / VIRTUAL_HEIGHT.value
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
// A preview is a picture, not a control: swallow touches before the
|
||||
// grid's own clickables see them, and give screen readers one label
|
||||
// instead of six weeks of day cells.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics { }
|
||||
// Measure the grid at a full phone viewport but report the scaled
|
||||
// size, so the node occupies exactly what it draws.
|
||||
//
|
||||
// Modifier.requiredSize would be the obvious way to force the larger
|
||||
// measurement, but it *centres* content that overflows the incoming
|
||||
// constraints — which pushed the grid to a negative offset and left
|
||||
// only its bottom-right corner inside the clip.
|
||||
.layout { measurable, _ ->
|
||||
val fullWidth = screenWidth.roundToPx()
|
||||
val fullHeight = VIRTUAL_HEIGHT.roundToPx()
|
||||
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
},
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
||||
when (style) {
|
||||
MonthViewStyle.Paged -> MonthGrid(
|
||||
state = sample.month,
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
)
|
||||
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
||||
state = sample.continuous,
|
||||
listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = itemIndexForMonth(
|
||||
monthIndexOf(YearMonth(today.year, today.month)),
|
||||
),
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
)
|
||||
MonthViewStyle.Dense -> DenseMonthGrid(
|
||||
state = sample.continuous,
|
||||
listState = rememberLazyListState(
|
||||
// Start a week above today's, so the preview shows a
|
||||
// stream running past the viewport rather than one
|
||||
// beginning at its top edge.
|
||||
initialFirstVisibleItemIndex =
|
||||
weekIndexOf(today, weekStart) - 1,
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
)
|
||||
MonthViewStyle.Split -> {
|
||||
SplitMonthGrid(
|
||||
state = sample.month,
|
||||
selected = today,
|
||||
showWeekNumbers = false,
|
||||
onSelectDay = {},
|
||||
)
|
||||
SplitDayPane(
|
||||
date = today,
|
||||
today = today,
|
||||
events = sample.month.instancesByDay[today].orEmpty(),
|
||||
zone = zone,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
onCreateEvent = {},
|
||||
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
||||
when (style) {
|
||||
MonthViewStyle.Paged -> MonthGrid(
|
||||
state = sample.month,
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
)
|
||||
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
||||
state = sample.continuous,
|
||||
listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = itemIndexForMonth(
|
||||
monthIndexOf(YearMonth(today.year, today.month)),
|
||||
),
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
)
|
||||
MonthViewStyle.Dense -> DenseMonthGrid(
|
||||
state = sample.continuous,
|
||||
listState = rememberLazyListState(
|
||||
// Start a week above today's, so the preview shows a stream
|
||||
// running past the viewport rather than one beginning at its
|
||||
// top edge.
|
||||
initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1,
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
)
|
||||
MonthViewStyle.Split -> {
|
||||
SplitMonthGrid(
|
||||
state = sample.month,
|
||||
selected = today,
|
||||
showWeekNumbers = false,
|
||||
onSelectDay = {},
|
||||
)
|
||||
SplitDayPane(
|
||||
date = today,
|
||||
today = today,
|
||||
events = sample.month.instancesByDay[today].orEmpty(),
|
||||
zone = zone,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
onCreateEvent = {},
|
||||
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewport the preview pretends to be: a phone's width by the height a
|
||||
* calendar view gets under the top bar. Scaling from a fixed height keeps the
|
||||
* three styles comparable — each shows the same slice of screen.
|
||||
*/
|
||||
private val VIRTUAL_HEIGHT = 440.dp
|
||||
private val SPLIT_PANE_HEIGHT = 170.dp
|
||||
|
||||
private class SampleMonth(
|
||||
@@ -170,9 +100,6 @@ private class SampleMonth(
|
||||
* A month's worth of stand-in events, laid out through the same
|
||||
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
|
||||
* exercises the real span, lane and overflow logic rather than approximating it.
|
||||
*
|
||||
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||
* event, so a token here would misrepresent what the grid actually renders.
|
||||
*/
|
||||
private fun sampleMonthState(
|
||||
today: LocalDate,
|
||||
@@ -181,7 +108,7 @@ private fun sampleMonthState(
|
||||
): SampleMonth {
|
||||
val ym = YearMonth(today.year, today.month)
|
||||
val first = LocalDate(ym.year, ym.month, 1)
|
||||
val events = sampleEvents(first, today, zone)
|
||||
val events = sampleMonthEvents(first, today, zone)
|
||||
|
||||
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
|
||||
val month = MonthUiState.Success(
|
||||
@@ -213,72 +140,3 @@ private fun sampleMonthState(
|
||||
)
|
||||
return SampleMonth(month, continuous)
|
||||
}
|
||||
|
||||
private val SAMPLE_COLORS = listOf(
|
||||
0xFF3F7BD4.toInt(),
|
||||
0xFFCE5B4C.toInt(),
|
||||
0xFF4E9A6A.toInt(),
|
||||
0xFF8A63C7.toInt(),
|
||||
)
|
||||
|
||||
private fun sampleEvents(
|
||||
firstOfMonth: LocalDate,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
): List<EventInstance> {
|
||||
var id = 0L
|
||||
fun next() = ++id
|
||||
|
||||
fun timed(day: LocalDate, hour: Int, length: Int, colorIndex: Int) = EventInstance(
|
||||
instanceId = next(),
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||
start = day.atTime(hour, 0).toInstant(zone),
|
||||
end = day.atTime(hour + length, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||
location = null,
|
||||
)
|
||||
|
||||
fun allDay(from: LocalDate, days: Int, colorIndex: Int) = EventInstance(
|
||||
instanceId = next(),
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||
// All-day events sit at UTC midnights with an exclusive end.
|
||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
isAllDay = true,
|
||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||
location = null,
|
||||
)
|
||||
|
||||
// Spread across the month so most weeks carry something, with one multi-day
|
||||
// bar to show a span bridging cells and a busy today for the split pane.
|
||||
return buildList {
|
||||
add(allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
||||
add(timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, 1, 0))
|
||||
add(timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, 2, 1))
|
||||
add(timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, 1, 3))
|
||||
add(timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, 1, 0))
|
||||
add(timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, 1, 2))
|
||||
add(timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, 2, 1))
|
||||
add(timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, 1, 3))
|
||||
// Today, so the split pane's list and the grid's dots both have content.
|
||||
add(timed(today, 9, 1, 0))
|
||||
add(timed(today, 12, 1, 1))
|
||||
add(timed(today, 15, 2, 3))
|
||||
}
|
||||
}
|
||||
|
||||
private val SAMPLE_TITLES = listOf(
|
||||
"Standup",
|
||||
"Lunch",
|
||||
"Review",
|
||||
"Gym",
|
||||
"Call",
|
||||
"Workshop",
|
||||
"Dentist",
|
||||
"Trip",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
/**
|
||||
* Wizard step shown only when everything you write to is a device-only calendar
|
||||
* (#163): the events exist nowhere but this phone, so losing it loses them.
|
||||
*
|
||||
* The primary action picks a folder and switches automatic backup on in one go.
|
||||
* Everything it sets is a normal Backup setting, adjustable afterwards.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BackupStep(
|
||||
onEnable: (Uri) -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val pickFolder = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri ->
|
||||
// Cancelling the picker decides nothing and leaves the step open.
|
||||
uri?.let(onEnable)
|
||||
}
|
||||
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.CloudOff) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = { runCatching { pickFolder.launch(null) } },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_backup_enable_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_backup_skip_button))
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_backup_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_backup_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.FolderOpen,
|
||||
title = stringResource(R.string.onboarding_backup_benefit_folder_title),
|
||||
body = stringResource(R.string.onboarding_backup_benefit_folder_body),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.Schedule,
|
||||
title = stringResource(R.string.onboarding_backup_benefit_daily_title),
|
||||
body = stringResource(R.string.onboarding_backup_benefit_daily_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -16,20 +17,25 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/** What, if anything, the brand mark is wearing in its corner. */
|
||||
internal enum class HeroBadge { Denied, Done }
|
||||
|
||||
/**
|
||||
* The app's adaptive launcher mark, reconstructed as a large branded squircle —
|
||||
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies
|
||||
* the shell; the mark stays app-local so each sibling keeps its own identity).
|
||||
* A lock badge overlays the corner when permission has been [denied].
|
||||
* A [badge] overlays the corner where the screen has a verdict to give: blocked,
|
||||
* or finished.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BrandHero(denied: Boolean) {
|
||||
internal fun BrandHero(badge: HeroBadge? = null) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -43,24 +49,56 @@ internal fun BrandHero(denied: Boolean) {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
if (denied) {
|
||||
// A small lock badge sits over the corner to signal "blocked".
|
||||
if (badge != null) {
|
||||
val container = when (badge) {
|
||||
HeroBadge.Denied -> MaterialTheme.colorScheme.errorContainer
|
||||
HeroBadge.Done -> MaterialTheme.colorScheme.primaryContainer
|
||||
}
|
||||
val onContainer = when (badge) {
|
||||
HeroBadge.Denied -> MaterialTheme.colorScheme.onErrorContainer
|
||||
HeroBadge.Done -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = 10.dp, y = 10.dp)
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.errorContainer),
|
||||
.background(container),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
imageVector = when (badge) {
|
||||
HeroBadge.Denied -> Icons.Filled.Lock
|
||||
HeroBadge.Done -> Icons.Filled.Check
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
tint = onContainer,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single [icon] in the brand squircle — the same silhouette as [BrandHero],
|
||||
* for the wizard steps that stand for a feature rather than for the app itself.
|
||||
*/
|
||||
@Composable
|
||||
internal fun IconHero(icon: ImageVector) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
.clip(RoundedCornerShape(34.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
/**
|
||||
* The wizard's closing screen (#163): the flow ends on a full screen saying so
|
||||
* rather than dropping into the calendar mid-gesture, and the brand mark wears
|
||||
* the check that the last step earned.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DoneStep(
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { BrandHero(HeroBadge.Done) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = onFinished,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_done_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_done_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_done_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
|
||||
/**
|
||||
* The M3 Expressive spatial spring the wizard's steps travel on. Read in
|
||||
* composable scope so it can be captured by `AnimatedContent`'s non-composable
|
||||
* transitionSpec lambda.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun rememberOnboardingSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||
|
||||
/**
|
||||
* Moving between wizard steps (#163): the steps sit side by side and the whole
|
||||
* width swipes across, position on a spring so the arriving step settles with a
|
||||
* little give rather than stopping dead.
|
||||
*
|
||||
* No cross-fade, unlike the calendar's shared-axis paging: two steps have
|
||||
* nothing in common, so fading one through the other smears. Both are opaque and
|
||||
* travel in lockstep, showing one screen pushing the other off.
|
||||
*
|
||||
* @param slideDir +1 = forward (incoming from the right), -1 = back.
|
||||
* @param spec spatial spec, typically [rememberOnboardingSlideSpec].
|
||||
* @param fadeSpec effects spec used for the whole transition under reduced
|
||||
* motion.
|
||||
* @param reduceMotion when true, drop the movement and cross-fade alone.
|
||||
*/
|
||||
internal fun onboardingSlideTransition(
|
||||
slideDir: Int,
|
||||
spec: FiniteAnimationSpec<IntOffset>,
|
||||
fadeSpec: FiniteAnimationSpec<Float>,
|
||||
reduceMotion: Boolean,
|
||||
): ContentTransform {
|
||||
if (reduceMotion) {
|
||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||
}
|
||||
val dir = if (slideDir == 0) 1 else slideDir
|
||||
return ContentTransform(
|
||||
targetContentEnter = slideInHorizontally(spec) { w -> dir * w },
|
||||
initialContentExit = slideOutHorizontally(spec) { w -> -dir * w },
|
||||
// Clipping to the animating container would shear both steps against the
|
||||
// viewport edge as they pass; there is no size change here to contain.
|
||||
sizeTransform = SizeTransform(clip = false),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
/** One screen of the first-launch wizard (#163), in the order they are shown. */
|
||||
enum class OnboardingStep {
|
||||
/** Required: nothing works without the calendar grant. */
|
||||
Permission,
|
||||
|
||||
/** Whether Calendula delivers reminder notifications itself. */
|
||||
Reminders,
|
||||
|
||||
/** Only when nothing you write to is synced anywhere — offers a backup. */
|
||||
Backup,
|
||||
|
||||
/** Which view the app opens on, chosen from a live preview. */
|
||||
View,
|
||||
|
||||
/** How the Month view lays itself out, chosen from a live preview. */
|
||||
MonthStyle,
|
||||
|
||||
/** Only on an install that has calendars the device is hiding (#75). */
|
||||
Visibility,
|
||||
|
||||
/** The closing screen: everything is answered and the app is ready. */
|
||||
Done,
|
||||
}
|
||||
|
||||
/**
|
||||
* Which steps this install owes and where it has got to. [steps] is the whole
|
||||
* flow — completed steps included — so the counter doesn't renumber as steps
|
||||
* are finished; [current] is null once nothing is left and the app opens.
|
||||
*/
|
||||
data class OnboardingPlan(
|
||||
val steps: List<OnboardingStep>,
|
||||
val current: OnboardingStep?,
|
||||
) {
|
||||
/** 1-based position of [current] in [steps], or 0 when the flow is done. */
|
||||
val index: Int get() = steps.indexOf(current) + 1
|
||||
|
||||
val total: Int get() = steps.size
|
||||
|
||||
/** A one-step flow is not a wizard: there is nothing to count down. */
|
||||
val showsProgress: Boolean get() = total > 1
|
||||
|
||||
/** The step before [current], or null at the start (and once finished). */
|
||||
val previous: OnboardingStep? get() = steps.getOrNull(index - 2).takeIf { current != null }
|
||||
|
||||
/**
|
||||
* Whether the wizard can step back. Everything is re-askable except the
|
||||
* calendar grant, which belongs to the system — once given there is nothing
|
||||
* for a back press to return to.
|
||||
*/
|
||||
val canGoBack: Boolean get() = previous != null && previous != OnboardingStep.Permission
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out the flow from what is stored.
|
||||
*
|
||||
* The optional steps belong to installs still working through their first run.
|
||||
* Before the grant that can only be guessed, so one that has not answered the
|
||||
* reminder step either counts as fresh — the same condition
|
||||
* [de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs.armOnboardingWizard]
|
||||
* commits to storage a moment later, and clears again on the closing screen so
|
||||
* a later re-grant is only the permission step.
|
||||
*
|
||||
* [backupApplies] is null until the calendar list can be read, i.e. for the
|
||||
* whole permission step; the backup step is assumed to apply until proven
|
||||
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
||||
* counter had not accounted for.
|
||||
*
|
||||
* The month-style step is *not* conditional on Month being the chosen view:
|
||||
* Month is reachable from the drawer whatever opens first, and a step that came
|
||||
* and went as the view is picked would move the counter under the user on the
|
||||
* step right before it.
|
||||
*
|
||||
* [visibilityArmed] carries the one-time notice that the device is hiding some
|
||||
* calendars (#75) — the only optional step an *existing* install can be given,
|
||||
* which is why it is not gated on `fresh`, and on its own it earns no closing
|
||||
* screen.
|
||||
*/
|
||||
fun onboardingPlan(
|
||||
hasPermission: Boolean,
|
||||
remindersDone: Boolean,
|
||||
wizardArmed: Boolean,
|
||||
backupDone: Boolean,
|
||||
viewDone: Boolean,
|
||||
monthStyleDone: Boolean,
|
||||
backupApplies: Boolean?,
|
||||
visibilityArmed: Boolean = false,
|
||||
visibilityDone: Boolean = false,
|
||||
doneShown: Boolean = false,
|
||||
): OnboardingPlan {
|
||||
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
||||
val steps = buildList {
|
||||
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
||||
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
||||
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
||||
if (fresh) {
|
||||
add(OnboardingStep.View)
|
||||
add(OnboardingStep.MonthStyle)
|
||||
}
|
||||
if (visibilityArmed) add(OnboardingStep.Visibility)
|
||||
if (fresh) add(OnboardingStep.Done)
|
||||
}
|
||||
val current = steps.firstOrNull { step ->
|
||||
when (step) {
|
||||
OnboardingStep.Permission -> !hasPermission
|
||||
OnboardingStep.Reminders -> !remindersDone
|
||||
OnboardingStep.Backup -> !backupDone
|
||||
OnboardingStep.View -> !viewDone
|
||||
OnboardingStep.MonthStyle -> !monthStyleDone
|
||||
OnboardingStep.Visibility -> !visibilityDone
|
||||
OnboardingStep.Done -> !doneShown
|
||||
}
|
||||
}
|
||||
return OnboardingPlan(steps, current)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.OnboardingProgress
|
||||
|
||||
/**
|
||||
* Renders the wizard's current [step] (#163). Each step commits its own answer
|
||||
* through [viewModel], which then hands the host the next one.
|
||||
*/
|
||||
@Composable
|
||||
fun OnboardingSteps(
|
||||
step: OnboardingStep,
|
||||
plan: OnboardingPlan,
|
||||
viewModel: OnboardingViewModel,
|
||||
onPermissionGranted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Collected for every step, not just the two that draw a preview: starting
|
||||
// it when the picker composes would leave that step blank as it slides in.
|
||||
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
||||
|
||||
// Coerced because the outgoing half of a transition may be a step the live
|
||||
// plan has since dropped — the backup step goes once the calendars say it
|
||||
// does not apply — and a "Step 0 of 3" flash is worse than a stale number.
|
||||
val index = (plan.steps.indexOf(step) + 1).coerceAtLeast(1)
|
||||
val progress: (@Composable () -> Unit)? = if (!plan.showsProgress) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
OnboardingProgress(
|
||||
step = index,
|
||||
total = plan.total,
|
||||
label = stringResource(R.string.onboarding_step_counter, index, plan.total),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The system back gesture does the same thing as the button, and stays off
|
||||
// on the first step so back still leaves the app.
|
||||
BackHandler(enabled = plan.canGoBack) { viewModel.goBack() }
|
||||
val navigationIcon: (@Composable () -> Unit)? = if (!plan.canGoBack) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
IconButton(onClick = viewModel::goBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.onboarding_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (step) {
|
||||
OnboardingStep.Permission -> PermissionScreen(
|
||||
onGranted = onPermissionGranted,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
)
|
||||
OnboardingStep.Reminders -> ReminderStep(
|
||||
onFinished = viewModel::finishReminders,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
OnboardingStep.Backup -> BackupStep(
|
||||
onEnable = viewModel::enableAutoBackup,
|
||||
onSkip = viewModel::skipBackup,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
// The choice is null only until DataStore's first emission; rendering
|
||||
// nothing for that frame beats a preview built on the wrong defaults.
|
||||
OnboardingStep.View -> choice?.let {
|
||||
ViewStep(
|
||||
choice = it,
|
||||
onSelect = viewModel::setDefaultView,
|
||||
onFinished = viewModel::finishView,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
}
|
||||
OnboardingStep.MonthStyle -> choice?.let {
|
||||
MonthStyleStep(
|
||||
choice = it,
|
||||
onSelect = viewModel::setMonthViewStyle,
|
||||
onFinished = viewModel::finishMonthStyle,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
}
|
||||
OnboardingStep.Visibility -> VisibilityStep(
|
||||
onFinished = viewModel::finishVisibility,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
OnboardingStep.Done -> DoneStep(
|
||||
onFinished = viewModel::finishOnboarding,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives the first-launch wizard (#163): decides which steps this install owes
|
||||
* (see [onboardingPlan]) and commits each answer.
|
||||
*
|
||||
* [plan] is null until the first stored emission, so the host renders nothing
|
||||
* for that frame rather than flashing a step the user has already answered.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class OnboardingViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val prefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val repository: CalendarRepository,
|
||||
private val scanner: ReminderScanner,
|
||||
private val visibilityReconciler: CalendarVisibilityReconciler,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Null until the host reports it — assuming either way would flash a screen. */
|
||||
private val permissionReported = MutableStateFlow<Boolean?>(null)
|
||||
|
||||
/**
|
||||
* The grant as the rest of the flow sees it: published only once the wizard
|
||||
* has been armed for it, since arming is a stored write and the frames
|
||||
* before it lands would collapse the plan to its one-step form.
|
||||
*/
|
||||
private val hasPermission: StateFlow<Boolean?> = permissionReported
|
||||
.mapLatest { granted ->
|
||||
if (granted == true) armWizardIfFresh()
|
||||
granted
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether the backup step applies: something of yours is worth exporting and
|
||||
* nothing you write to is synced anywhere. Null while the calendar list
|
||||
* cannot be read: every frame before the grant, and any read that fails —
|
||||
* an empty list is also how a null cursor arrives, and guessing false there
|
||||
* would drop the step for the whole session.
|
||||
*/
|
||||
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
|
||||
if (granted != true) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
repository.calendars()
|
||||
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
|
||||
.catch { emit(null) }
|
||||
.flowOn(io)
|
||||
}
|
||||
}
|
||||
|
||||
private val flags: Flow<OnboardingFlags> = combine(
|
||||
prefs.reminderOnboardingDone,
|
||||
prefs.onboardingWizardArmed,
|
||||
prefs.onboardingBackupDone,
|
||||
prefs.onboardingViewDone,
|
||||
prefs.onboardingMonthStyleDone,
|
||||
::OnboardingFlags,
|
||||
)
|
||||
|
||||
/** The closing steps' own flags, and whether the notice applies at all. */
|
||||
private val closingFlags: Flow<ClosingFlags> = combine(
|
||||
calendarPrefs.visibilityNoticePending.map { it == true },
|
||||
prefs.onboardingVisibilityDone,
|
||||
prefs.onboardingDoneShown,
|
||||
::ClosingFlags,
|
||||
)
|
||||
|
||||
val plan: StateFlow<OnboardingPlan?> =
|
||||
combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
|
||||
granted?.let {
|
||||
onboardingPlan(
|
||||
hasPermission = it,
|
||||
remindersDone = stored.remindersDone,
|
||||
wizardArmed = stored.wizardArmed,
|
||||
backupDone = stored.backupDone,
|
||||
viewDone = stored.viewDone,
|
||||
monthStyleDone = stored.monthStyleDone,
|
||||
backupApplies = backup,
|
||||
visibilityArmed = closing.visibilityArmed,
|
||||
visibilityDone = closing.visibilityDone,
|
||||
doneShown = closing.doneShown,
|
||||
)
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/** The current view preferences, for the view step's initial selection. */
|
||||
val viewChoice: StateFlow<ViewChoice?> = combine(
|
||||
prefs.defaultView,
|
||||
prefs.monthViewStyle,
|
||||
prefs.weekStart,
|
||||
::ViewChoice,
|
||||
).stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
fun setHasPermission(granted: Boolean) {
|
||||
permissionReported.value = granted
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the wizard for an install that has yet to answer the reminder step,
|
||||
* whatever route the grant took. Gating this on the in-app grant would
|
||||
* strand anyone who granted on Android's own settings screen and came back
|
||||
* to a restarted process, leaving them the reminder step alone.
|
||||
*/
|
||||
private suspend fun armWizardIfFresh() {
|
||||
if (!prefs.reminderOnboardingDone.first()) prefs.armOnboardingWizard()
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the in-app grant. The launch scan ran before it and bailed out
|
||||
* without arming anything, so the first alarm would otherwise wait on the
|
||||
* daily worker.
|
||||
*/
|
||||
fun onPermissionGranted() {
|
||||
scanner.scanInBackground()
|
||||
}
|
||||
|
||||
/** Close the reminder step, recording whether notifications stay on. */
|
||||
fun finishReminders(remindersEnabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(remindersEnabled)
|
||||
prefs.setReminderOnboardingDone()
|
||||
// Nothing else re-arms the scan: turning reminders off cancels the
|
||||
// alarm (#75).
|
||||
scanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-ask the step before the current one, by clearing its answer. The plan
|
||||
* derives the position from those answers, so un-setting one *is* the back
|
||||
* navigation. The calendar grant belongs to the system and cannot be
|
||||
* returned to, which [OnboardingPlan.canGoBack] already refuses.
|
||||
*/
|
||||
fun goBack() {
|
||||
viewModelScope.launch {
|
||||
when (plan.value?.previous) {
|
||||
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
||||
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
||||
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
||||
OnboardingStep.MonthStyle -> prefs.setOnboardingMonthStyleDone(false)
|
||||
OnboardingStep.Visibility -> prefs.setOnboardingVisibilityDone(false)
|
||||
OnboardingStep.Done -> prefs.setOnboardingDoneShown(false)
|
||||
OnboardingStep.Permission, null -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the backup step without setting anything up. */
|
||||
fun skipBackup() {
|
||||
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn automatic backup on, writing to the folder the user just picked
|
||||
* (taking a durable write grant so background runs can keep writing), and
|
||||
* close the step.
|
||||
*/
|
||||
fun enableAutoBackup(folder: Uri) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
folder,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
}
|
||||
prefs.setAutoBackupFolderUri(folder.toString())
|
||||
prefs.setAutoBackupEnabled(true)
|
||||
BackupScheduler.apply(
|
||||
context = context,
|
||||
enabled = true,
|
||||
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
||||
hasFolder = true,
|
||||
)
|
||||
// Immediate feedback: the first backup lands while onboarding runs.
|
||||
BackupScheduler.runNow(context)
|
||||
prefs.setOnboardingBackupDone()
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefaultView(view: CalendarView) {
|
||||
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||
}
|
||||
|
||||
fun setMonthViewStyle(style: MonthViewStyle) {
|
||||
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||
}
|
||||
|
||||
/** Close a picker step — the choice itself is saved as it is made. */
|
||||
fun finishView() {
|
||||
viewModelScope.launch { prefs.setOnboardingViewDone() }
|
||||
}
|
||||
|
||||
fun finishMonthStyle() {
|
||||
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
|
||||
}
|
||||
|
||||
/** Acknowledge the calendar-visibility notice (#75). */
|
||||
fun finishVisibility() {
|
||||
viewModelScope.launch {
|
||||
prefs.setOnboardingVisibilityDone()
|
||||
// Retiring the notice drops its step, so it waits until nothing
|
||||
// follows — the count must not fall on the way to a later screen.
|
||||
if (plan.value?.steps?.last() == OnboardingStep.Visibility) retireVisibilityNotice()
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the wizard from its last screen. */
|
||||
fun finishOnboarding() {
|
||||
viewModelScope.launch {
|
||||
prefs.finishOnboardingWizard()
|
||||
retireVisibilityNotice()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the one-time visibility notice away for good — nothing else clears it
|
||||
* now that the wizard owns it, and left standing it would keep its step in
|
||||
* every later plan.
|
||||
*/
|
||||
private suspend fun retireVisibilityNotice() {
|
||||
if (calendarPrefs.visibilityNoticePending.first() == true) {
|
||||
calendarPrefs.setVisibilityNoticePending(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the retired app-local calendar visibility into the device's own, and
|
||||
* arm the notice if this install has anything to announce. Runs whenever the
|
||||
* app comes up holding the permission, however it was granted — one taken on
|
||||
* Android's app-settings screen never reaches the permission step's callback.
|
||||
*/
|
||||
fun reconcileCalendarVisibility() {
|
||||
viewModelScope.launch { visibilityReconciler.run() }
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored answers the plan is derived from. */
|
||||
private data class OnboardingFlags(
|
||||
val remindersDone: Boolean,
|
||||
val wizardArmed: Boolean,
|
||||
val backupDone: Boolean,
|
||||
val viewDone: Boolean,
|
||||
val monthStyleDone: Boolean,
|
||||
)
|
||||
|
||||
/** The tail of the flow: the visibility notice and the closing screen. */
|
||||
private data class ClosingFlags(
|
||||
val visibilityArmed: Boolean,
|
||||
val visibilityDone: Boolean,
|
||||
val doneShown: Boolean,
|
||||
)
|
||||
|
||||
/** What the view step starts from, and renders its previews with. */
|
||||
data class ViewChoice(
|
||||
val defaultView: CalendarView,
|
||||
val monthViewStyle: MonthViewStyle,
|
||||
val weekStart: WeekStartPref,
|
||||
)
|
||||
|
||||
/**
|
||||
* True when this device holds events that live nowhere else: at least one local
|
||||
* calendar worth exporting, and no writable synced calendar to carry them.
|
||||
* A read-only subscription is not a backup, so it does not count.
|
||||
*/
|
||||
private fun List<CalendarSource>.backupApplies(): Boolean {
|
||||
val exportable = any { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||
val syncedTarget = any { !it.isLocal && it.canModifyContents }
|
||||
return exportable && !syncedTarget
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
@@ -10,19 +10,15 @@ import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -32,13 +28,11 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
@@ -52,6 +46,7 @@ private val CALENDAR_PERMISSIONS = arrayOf(
|
||||
fun PermissionScreen(
|
||||
onGranted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
viewModel: PermissionViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -76,6 +71,7 @@ fun PermissionScreen(
|
||||
when (state) {
|
||||
is PermissionUiState.Rationale -> RationaleContent(
|
||||
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
||||
progress = progress,
|
||||
modifier = modifier,
|
||||
)
|
||||
is PermissionUiState.Denied -> DeniedContent(
|
||||
@@ -83,6 +79,7 @@ fun PermissionScreen(
|
||||
viewModel.onRetry()
|
||||
launcher.launch(CALENDAR_PERMISSIONS)
|
||||
},
|
||||
progress = progress,
|
||||
modifier = modifier,
|
||||
)
|
||||
is PermissionUiState.Granted -> {
|
||||
@@ -94,11 +91,14 @@ fun PermissionScreen(
|
||||
@Composable
|
||||
private fun RationaleContent(
|
||||
onRequest: () -> Unit,
|
||||
progress: (@Composable () -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
hero = { BrandHero(denied = false) },
|
||||
progress = progress,
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { BrandHero() },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = onRequest,
|
||||
@@ -116,16 +116,8 @@ private fun RationaleContent(
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
PrivacyFootnote()
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
letterSpacing = 2.sp,
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.permission_rationale_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
@@ -139,14 +131,8 @@ private fun RationaleContent(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.Lock,
|
||||
title = stringResource(R.string.permission_benefit_private_title),
|
||||
body = stringResource(R.string.permission_benefit_private_body),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.CalendarMonth,
|
||||
title = stringResource(R.string.permission_benefit_sync_title),
|
||||
@@ -154,9 +140,9 @@ private fun RationaleContent(
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.VisibilityOff,
|
||||
title = stringResource(R.string.permission_benefit_privacy_title),
|
||||
body = stringResource(R.string.permission_benefit_privacy_body),
|
||||
icon = Icons.Filled.Lock,
|
||||
title = stringResource(R.string.permission_benefit_private_title),
|
||||
body = stringResource(R.string.permission_benefit_private_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -164,12 +150,14 @@ private fun RationaleContent(
|
||||
@Composable
|
||||
private fun DeniedContent(
|
||||
onRetry: () -> Unit,
|
||||
progress: (@Composable () -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
hero = { BrandHero(denied = true) },
|
||||
progress = progress,
|
||||
hero = { BrandHero(HeroBadge.Denied) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -208,25 +196,3 @@ private fun DeniedContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrivacyFootnote() {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.permission_privacy_footnote),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
sealed interface PermissionUiState {
|
||||
data object Rationale : PermissionUiState
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
@@ -8,34 +8,25 @@ import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* One-time onboarding step after the calendar grant (v1.4): explains that
|
||||
* Wizard step after the calendar grant (v1.4): explains that
|
||||
* Calendula delivers reminder notifications itself, warns about duplicates
|
||||
* when a second calendar app has notifications on, and requests
|
||||
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
|
||||
@@ -45,9 +36,11 @@ import de.jeanlucmakiola.calendula.R
|
||||
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderOnboardingScreen(
|
||||
fun ReminderStep(
|
||||
onFinished: (remindersEnabled: Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
@@ -55,7 +48,10 @@ fun ReminderOnboardingScreen(
|
||||
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
hero = { BellHero() },
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.NotificationsActive) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -80,13 +76,6 @@ fun ReminderOnboardingScreen(
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
letterSpacing = 2.sp,
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.reminder_onboarding_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
@@ -100,7 +89,7 @@ fun ReminderOnboardingScreen(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.NotificationsActive,
|
||||
@@ -113,30 +102,5 @@ fun ReminderOnboardingScreen(
|
||||
title = stringResource(R.string.reminder_benefit_duplicates_title),
|
||||
body = stringResource(R.string.reminder_benefit_duplicates_body),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.Tune,
|
||||
title = stringResource(R.string.reminder_benefit_reversible_title),
|
||||
body = stringResource(R.string.reminder_benefit_reversible_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A bell in the brand squircle — same silhouette as the permission hero. */
|
||||
@Composable
|
||||
private fun BellHero() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
.clip(RoundedCornerShape(34.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.NotificationsActive,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.week.WeekViewPreview
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/**
|
||||
* Wizard step: which view the app opens on (#163). Built like the Settings
|
||||
* picker it mirrors — a live preview over connected grouped rows. Selecting
|
||||
* applies immediately and leaves the step open; the preview is the confirmation.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ViewStep(
|
||||
choice: ViewChoice,
|
||||
onSelect: (CalendarView) -> Unit,
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val weekStart = choice.weekStart.resolveFirstDay(currentLocale())
|
||||
|
||||
StepPickerScaffold(
|
||||
title = stringResource(R.string.onboarding_view_title),
|
||||
onFinished = onFinished,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
) {
|
||||
StepPreview(selected = choice.defaultView) { view ->
|
||||
when (view) {
|
||||
CalendarView.Month -> MonthStylePreview(
|
||||
style = choice.monthViewStyle,
|
||||
weekStart = weekStart,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
|
||||
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
||||
}
|
||||
}
|
||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||
val isSelected = view == choice.defaultView
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||
selected = isSelected,
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = view.icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(view) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wizard step: how the Month view lays itself out (#163). The same shape as
|
||||
* [ViewStep] and as the Settings chooser this duplicates, down to the selected
|
||||
* style's own blurb sitting under its preview rather than on every row.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MonthStyleStep(
|
||||
choice: ViewChoice,
|
||||
onSelect: (MonthViewStyle) -> Unit,
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val weekStart = choice.weekStart.resolveFirstDay(currentLocale())
|
||||
val options = MonthViewStyle.entries
|
||||
|
||||
StepPickerScaffold(
|
||||
title = stringResource(R.string.onboarding_month_style_title),
|
||||
onFinished = onFinished,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
) {
|
||||
StepPreview(selected = choice.monthViewStyle) { style ->
|
||||
MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||
}
|
||||
PickerDescription(stringResource(choice.monthViewStyle.descriptionRes))
|
||||
options.forEachIndexed { index, style ->
|
||||
val isSelected = style == choice.monthViewStyle
|
||||
GroupedRow(
|
||||
title = stringResource(style.labelRes),
|
||||
position = positionOf(index, options.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(style) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell both picker steps share: the wizard chrome around a title and a
|
||||
* full-bleed column, since grouped rows carry their own inset.
|
||||
*/
|
||||
@Composable
|
||||
private fun StepPickerScaffold(
|
||||
title: String,
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.xs,
|
||||
contentPadding = 0.dp,
|
||||
// The preview gets the room it needs; Continue scrolls in under the
|
||||
// options rather than pinning to the bottom.
|
||||
scrollingActions = true,
|
||||
hero = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = GroupedListInset),
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
Button(
|
||||
onClick = onFinished,
|
||||
// Full-bleed step: the button takes the grouped rows' own inset
|
||||
// and ends up as wide as the options above it.
|
||||
modifier = Modifier
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_view_continue_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
body = body,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The live preview, framed like the picker's. The wider gap below keeps the rows
|
||||
* reading as a group of their own rather than as the preview's caption.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T> StepPreview(selected: T, content: @Composable (T) -> Unit) {
|
||||
ViewPreviewFrame(
|
||||
selected = selected,
|
||||
label = "step-preview",
|
||||
modifier = Modifier.padding(top = OnboardingSpace.xs, bottom = OnboardingSpace.md),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/** The same size the Settings chooser gives it. */
|
||||
private val PREVIEW_HEIGHT: Dp = 280.dp
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
/**
|
||||
* Wizard step for the one-time notice that Calendula now follows the device's
|
||||
* own per-calendar visibility and some of yours are switched off (#75, #163).
|
||||
*
|
||||
* Nothing to decide here — the app does not switch those calendars back on,
|
||||
* since that would un-hide them everywhere else too.
|
||||
*/
|
||||
@Composable
|
||||
internal fun VisibilityStep(
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.VisibilityOff) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = onFinished,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_visibility_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_visibility_notice_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_visibility_notice_message),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Gates the one-time reminder onboarding step (v1.4) shown after the calendar
|
||||
* grant. [onboardingDone] is null until DataStore's first emission so the
|
||||
* step neither flashes for users who completed it nor gets skipped.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ReminderOnboardingViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
private val scanner: ReminderScanner,
|
||||
) : ViewModel() {
|
||||
|
||||
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
||||
.map { done -> done as Boolean? }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/** Close the step, recording whether reminder notifications stay on. */
|
||||
fun finish(remindersEnabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(remindersEnabled)
|
||||
prefs.setReminderOnboardingDone()
|
||||
// Nothing else re-arms the scan: turning reminders off cancels the
|
||||
// alarm (#75).
|
||||
scanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-scan after the calendar permission is granted. The launch scan runs
|
||||
* before the grant and bails out without arming anything, so without this
|
||||
* the first alarm waits on the daily worker.
|
||||
*/
|
||||
fun rearmAfterGrant() {
|
||||
scanner.scanInBackground()
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,108 @@
|
||||
package de.jeanlucmakiola.calendula.ui.search
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.SearchOff
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.MatchSpan
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SnackChip
|
||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.Instant as JavaInstant
|
||||
@@ -69,11 +110,13 @@ import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
/** How long the delete confirmation chip stays up, matching a short snackbar. */
|
||||
private const val CHIP_MILLIS = 4_000L
|
||||
|
||||
/**
|
||||
* Full-text event search (top-bar entry). Type a query → matching events
|
||||
* (title / location / description) across the whole calendar, newest-relevant
|
||||
* first; tap a result to open its detail. A full-screen overlay hosted by
|
||||
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
||||
* Full-text event search over title / location / description: tap a result to
|
||||
* open its detail, long-press to select several and delete them at once (#80).
|
||||
* A full-screen overlay hosted by [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -85,63 +128,79 @@ fun SearchScreen(
|
||||
) {
|
||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val selection by viewModel.selection.collectAsStateWithLifecycle()
|
||||
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val context = LocalContext.current
|
||||
val inSelection = selection.isNotEmpty()
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Each fresh open starts blank and straight into typing. The ViewModel is
|
||||
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
|
||||
// resets a previous search. Peeking a result doesn't re-run this (the screen
|
||||
// stays composed under the detail), so backing out keeps the query.
|
||||
// The ViewModel is activity-scoped, so a re-enter is what resets the last
|
||||
// search, its selection and any delete receipt still up. Peeking a result
|
||||
// doesn't re-run this — the screen stays composed under the detail.
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.setQuery("")
|
||||
viewModel.clearSelection()
|
||||
viewModel.consumeDeleteResult()
|
||||
}
|
||||
// The field only exists out of selection mode, so a recreation that lands
|
||||
// mid-selection has to wait for the clear above before it can be focused.
|
||||
var focusPending by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(inSelection) {
|
||||
if (inSelection || !focusPending) return@LaunchedEffect
|
||||
focusPending = false
|
||||
focusRequester.requestFocus()
|
||||
keyboard?.show()
|
||||
}
|
||||
|
||||
// Same in-place WRITE_CALENDAR upgrade the detail screen does: a v1.0 install
|
||||
// holds only READ_CALENDAR, and granting continues into the held delete.
|
||||
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) pendingWrite?.invoke()
|
||||
pendingWrite = null
|
||||
}
|
||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) {
|
||||
action()
|
||||
} else {
|
||||
pendingWrite = action
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
|
||||
// Back leaves selection mode before it leaves the screen, and without the
|
||||
// predictive-back scale-out — that would read as search closing and snapping
|
||||
// back. The two handlers are mutually exclusive on [inSelection].
|
||||
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.predictiveBack(onBack = onBack),
|
||||
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
InlineTextField(
|
||||
value = query,
|
||||
onValueChange = viewModel::setQuery,
|
||||
placeholder = stringResource(R.string.search_hint),
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Search,
|
||||
onImeAction = { keyboard?.hide() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.search_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.setQuery("") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.search_clear),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
SearchTopBar(
|
||||
inSelection = inSelection,
|
||||
query = query,
|
||||
selectedCount = selection.size,
|
||||
focusRequester = focusRequester,
|
||||
onQueryChange = viewModel::setQuery,
|
||||
onImeAction = { keyboard?.hide() },
|
||||
onBack = onBack,
|
||||
onCloseSelection = viewModel::clearSelection,
|
||||
onSelectAll = viewModel::selectAll,
|
||||
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
// imePadding shrinks the content by the keyboard, so the centered
|
||||
// idle/empty message re-centres in the space above it (and the results
|
||||
// list lifts clear of the keyboard too).
|
||||
// imePadding so the idle/empty message re-centres above the keyboard and
|
||||
// the results list lifts clear of it.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -158,52 +217,314 @@ fun SearchScreen(
|
||||
text = stringResource(R.string.search_empty, s.query),
|
||||
)
|
||||
is SearchUiState.Results -> SearchResults(
|
||||
events = s.events,
|
||||
results = s,
|
||||
selection = selection,
|
||||
inSelection = inSelection,
|
||||
onEventClick = onEventClick,
|
||||
onToggle = viewModel::toggleSelection,
|
||||
)
|
||||
}
|
||||
DeleteOutcomeChip(
|
||||
deleteState = deleteState,
|
||||
onConsume = viewModel::consumeDeleteResult,
|
||||
modifier = Modifier.align(Alignment.BottomStart),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResults(
|
||||
events: List<EventInstance>,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = events,
|
||||
key = { _, event -> event.eventId },
|
||||
) { index, event ->
|
||||
SearchResultRow(
|
||||
event = event,
|
||||
position = positionOf(index, events.size),
|
||||
modifier = animateItemMotion(),
|
||||
onClick = { onEventClick(event) },
|
||||
if (showDeleteDialog) {
|
||||
val count = selection.size
|
||||
// One decision for the whole batch: the scope question is only asked when
|
||||
// a recurring event is in it, and one-offs ignore whatever comes back.
|
||||
if (viewModel.selectionHasRecurring()) {
|
||||
RecurringScopeDialog(
|
||||
title = stringResource(R.string.event_delete_recurring_title),
|
||||
onSelect = { scope ->
|
||||
showDeleteDialog = false
|
||||
viewModel.deleteSelected(scope)
|
||||
},
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
)
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = {
|
||||
Text(pluralStringResource(R.plurals.search_delete_title, count, count))
|
||||
},
|
||||
text = { Text(stringResource(R.string.event_delete_body)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteDialog = false
|
||||
viewModel.deleteSelected(RecurringWriteScope.AllEvents)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.event_detail_delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The search field and the contextual selection bar as one bar: the bar stays
|
||||
* in place across the swap, easing its colour and fading its slots through.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun SearchTopBar(
|
||||
inSelection: Boolean,
|
||||
query: String,
|
||||
selectedCount: Int,
|
||||
focusRequester: FocusRequester,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onImeAction: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onCloseSelection: () -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val container by animateColorAsState(
|
||||
targetValue = if (inSelection) {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
label = "search-top-bar-color",
|
||||
)
|
||||
// Held past the selection emptying, so the count has something to draw while
|
||||
// it fades out.
|
||||
var lastCount by remember { mutableIntStateOf(0) }
|
||||
if (selectedCount > 0) lastCount = selectedCount
|
||||
|
||||
// Read here so the non-composable transitionSpec lambdas can capture it.
|
||||
val slotSwap = fadeThrough()
|
||||
|
||||
TopAppBar(
|
||||
title = {
|
||||
AnimatedContent(
|
||||
targetState = inSelection,
|
||||
transitionSpec = { slotSwap },
|
||||
label = "search-top-bar-title",
|
||||
) { selecting ->
|
||||
if (selecting) {
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.search_selected_count,
|
||||
lastCount,
|
||||
lastCount,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
InlineTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = stringResource(R.string.search_hint),
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Search,
|
||||
onImeAction = onImeAction,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
AnimatedContent(
|
||||
targetState = inSelection,
|
||||
transitionSpec = { slotSwap },
|
||||
label = "search-top-bar-navigation",
|
||||
) { selecting ->
|
||||
if (selecting) {
|
||||
IconButton(onClick = onCloseSelection) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.search_selection_close),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.search_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
AnimatedContent(
|
||||
targetState = inSelection,
|
||||
transitionSpec = { slotSwap },
|
||||
label = "search-top-bar-actions",
|
||||
) { selecting ->
|
||||
Row {
|
||||
if (selecting) {
|
||||
IconButton(onClick = onSelectAll) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SelectAll,
|
||||
contentDescription = stringResource(R.string.search_select_all),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(
|
||||
R.string.search_delete_selected,
|
||||
),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
} else if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.search_clear),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = container),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SearchResults(
|
||||
results: SearchUiState.Results,
|
||||
selection: Set<Long>,
|
||||
inSelection: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onToggle: (Long) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// No horizontal inset: GroupedRow already insets itself.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
results.results.months.forEach { month ->
|
||||
stickyHeader(key = "month-${month.year}-${month.monthNumber}") {
|
||||
SearchSectionHeader(text = monthLabel(month))
|
||||
}
|
||||
hitRows(month.hits, results, selection, inSelection, onEventClick, onToggle)
|
||||
}
|
||||
// Held back from the calendar above: matched only in the notes.
|
||||
if (results.results.inDescriptions.isNotEmpty()) {
|
||||
stickyHeader(key = "in-descriptions") {
|
||||
SearchSectionHeader(text = stringResource(R.string.search_in_descriptions))
|
||||
}
|
||||
hitRows(
|
||||
results.results.inDescriptions,
|
||||
results,
|
||||
selection,
|
||||
inSelection,
|
||||
onEventClick,
|
||||
onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One section's rows, sharing a grouped-card run. */
|
||||
private fun LazyListScope.hitRows(
|
||||
hits: List<SearchHit>,
|
||||
results: SearchUiState.Results,
|
||||
selection: Set<Long>,
|
||||
inSelection: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onToggle: (Long) -> Unit,
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = hits,
|
||||
key = { _, hit -> hit.event.eventId },
|
||||
) { index, hit ->
|
||||
val event = hit.event
|
||||
val deletable = results.isDeletable(event)
|
||||
SearchResultRow(
|
||||
hit = hit,
|
||||
position = positionOf(index, hits.size),
|
||||
modifier = animateItemMotion(),
|
||||
selected = event.eventId in selection,
|
||||
// A read-only calendar's row can't join a batch, so in selection
|
||||
// mode it goes quiet rather than offering a checkbox that fails.
|
||||
inSelection = inSelection,
|
||||
selectable = deletable,
|
||||
onClick = when {
|
||||
inSelection && deletable -> ({ onToggle(event.eventId) })
|
||||
inSelection -> null
|
||||
else -> ({ onEventClick(event) })
|
||||
},
|
||||
onLongClick = { onToggle(event.eventId) }.takeIf { deletable && !inSelection },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pinned section label, styled like the agenda's day headers. */
|
||||
@Composable
|
||||
private fun SearchSectionHeader(text: String) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "August 2026" in the locale's own field order. */
|
||||
@Composable
|
||||
private fun monthLabel(month: SearchMonth): String {
|
||||
val locale = currentLocale()
|
||||
return remember(month.year, month.monthNumber, locale) {
|
||||
localizedDateFormatter(locale, "LLLLy")
|
||||
.format(java.time.LocalDate.of(month.year, month.monthNumber, 1))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResultRow(
|
||||
event: EventInstance,
|
||||
hit: SearchHit,
|
||||
position: Position,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
selected: Boolean = false,
|
||||
inSelection: Boolean = false,
|
||||
selectable: Boolean = true,
|
||||
onClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val event = hit.event
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
// On a picked row the headline is already recoloured for the secondary
|
||||
// container, so the match is carried by weight alone there.
|
||||
val highlight = SpanStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
GroupedRow(
|
||||
modifier = modifier,
|
||||
title = event.title,
|
||||
summary = searchSummary(event),
|
||||
// Faded like a past event anywhere else in the app — search reaches back
|
||||
// through the whole history.
|
||||
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = marked(event.title, hit.titleSpans, highlight),
|
||||
summary = searchSummary(hit, highlight),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
selected = selected,
|
||||
dimmed = inSelection && !selectable,
|
||||
leading = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -212,21 +533,97 @@ private fun SearchResultRow(
|
||||
.background(eventAccent(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
trailing = if (inSelection && selectable) {
|
||||
{
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
|
||||
/**
|
||||
* The batch's receipt: how many went, and whether any refused. No Undo — the
|
||||
* provider can't put a deleted event back.
|
||||
*/
|
||||
@Composable
|
||||
private fun searchSummary(event: EventInstance): String {
|
||||
private fun DeleteOutcomeChip(
|
||||
deleteState: BulkDeleteUiState,
|
||||
onConsume: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val message = when (deleteState) {
|
||||
is BulkDeleteUiState.Done -> when {
|
||||
deleteState.failed > 0 -> stringResource(
|
||||
R.string.search_delete_partial,
|
||||
deleteState.deleted,
|
||||
deleteState.failed,
|
||||
)
|
||||
else -> pluralStringResource(
|
||||
R.plurals.search_delete_done,
|
||||
deleteState.deleted,
|
||||
deleteState.deleted,
|
||||
)
|
||||
}
|
||||
is BulkDeleteUiState.NeedsPermission -> if (deleteState.deleted > 0) {
|
||||
stringResource(R.string.search_delete_denied_partial, deleteState.deleted)
|
||||
} else {
|
||||
stringResource(R.string.event_delete_write_denied)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
// Held past the state being consumed so the chip has something to draw while
|
||||
// it springs back out.
|
||||
val shown = remember { mutableStateOf("") }
|
||||
if (message != null && shown.value != message) shown.value = message
|
||||
|
||||
LaunchedEffect(deleteState) {
|
||||
if (message == null) return@LaunchedEffect
|
||||
delay(CHIP_MILLIS)
|
||||
onConsume()
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
||||
.height(SnackChipHeight),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
SnackChip(visible = message != null, message = shown.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** [text] with every matched run emphasised. */
|
||||
private fun marked(text: String, spans: List<MatchSpan>, style: SpanStyle): AnnotatedString =
|
||||
if (spans.isEmpty()) {
|
||||
AnnotatedString(text)
|
||||
} else {
|
||||
buildAnnotatedString {
|
||||
append(text)
|
||||
spans.forEach { addStyle(style, it.start, it.end) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Wed, 17 Jun 2026 · 09:00 · Office", with a second line excerpting the
|
||||
* description when that is where the query matched.
|
||||
*/
|
||||
@Composable
|
||||
private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString {
|
||||
val event = hit.event
|
||||
val locale = currentLocale()
|
||||
val zone = remember { ZoneId.systemDefault() }
|
||||
val start = remember(event.start, zone) {
|
||||
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
||||
}
|
||||
// From the shared span rule, not [start]: an all-day event sits at UTC
|
||||
// midnight and would name the day before west of UTC (#82). The clock time
|
||||
// below stays device-zone — it is only rendered for timed events.
|
||||
// From the span rule, not [start]: an all-day event sits at UTC midnight and
|
||||
// would name the day before west of UTC (#82).
|
||||
val dateText = remember(event.start, event.end, event.isAllDay, locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
||||
@@ -237,8 +634,23 @@ private fun searchSummary(event: EventInstance): String {
|
||||
} else {
|
||||
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||
}
|
||||
val base = "$dateText · $timeText"
|
||||
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
||||
return buildAnnotatedString {
|
||||
append("$dateText · $timeText")
|
||||
event.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||
append(" · ")
|
||||
val offset = length
|
||||
append(location)
|
||||
hit.locationSpans.forEach {
|
||||
addStyle(highlight, offset + it.start, offset + it.end)
|
||||
}
|
||||
}
|
||||
hit.descriptionSnippet?.let { snippet ->
|
||||
append("\n")
|
||||
val offset = length
|
||||
append(snippet.text)
|
||||
snippet.spans.forEach { addStyle(highlight, offset + it.start, offset + it.end) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -6,6 +6,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SearchResults
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -14,12 +18,19 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -33,8 +44,30 @@ sealed interface SearchUiState {
|
||||
/** A query ran but matched nothing. */
|
||||
data class Empty(val query: String) : SearchUiState
|
||||
|
||||
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
|
||||
data class Results(val events: List<EventInstance>) : SearchUiState
|
||||
/** Matches by month — see [de.jeanlucmakiola.calendula.domain.EventSearch]. */
|
||||
data class Results(
|
||||
val results: SearchResults,
|
||||
/** Rows from these are kept out of selection rather than failing later. */
|
||||
val readOnlyCalendarIds: Set<Long> = emptySet(),
|
||||
) : SearchUiState {
|
||||
val events: List<EventInstance> get() = results.allHits.map { it.event }
|
||||
|
||||
fun isDeletable(event: EventInstance): Boolean =
|
||||
event.calendarId !in readOnlyCalendarIds
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome of deleting the selected results (#80). */
|
||||
sealed interface BulkDeleteUiState {
|
||||
data object Idle : BulkDeleteUiState
|
||||
|
||||
data object Deleting : BulkDeleteUiState
|
||||
|
||||
/** Terminal: [deleted] events went, [failed] wouldn't. */
|
||||
data class Done(val deleted: Int, val failed: Int) : BulkDeleteUiState
|
||||
|
||||
/** WRITE_CALENDAR was revoked after [deleted] went; the rest wasn't tried. */
|
||||
data class NeedsPermission(val deleted: Int) : BulkDeleteUiState
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@@ -47,17 +80,42 @@ class SearchViewModel @Inject constructor(
|
||||
private val _query = MutableStateFlow("")
|
||||
val query: StateFlow<String> = _query.asStateFlow()
|
||||
|
||||
val state: StateFlow<SearchUiState> = _query
|
||||
.debounce(250L)
|
||||
.map { it.trim() }
|
||||
.distinctUntilChanged()
|
||||
/** Bumped after a delete so the same query re-runs against the changed provider. */
|
||||
private val _reload = MutableStateFlow(0)
|
||||
|
||||
private val _selection = MutableStateFlow<Set<Long>>(emptySet())
|
||||
|
||||
/** Event ids picked in selection mode; empty means the mode is off. */
|
||||
val selection: StateFlow<Set<Long>> = _selection.asStateFlow()
|
||||
|
||||
private val _deleteState = MutableStateFlow<BulkDeleteUiState>(BulkDeleteUiState.Idle)
|
||||
val deleteState: StateFlow<BulkDeleteUiState> = _deleteState.asStateFlow()
|
||||
|
||||
val state: StateFlow<SearchUiState> = combine(
|
||||
_query.debounce(250L).map { it.trim() }.distinctUntilChanged(),
|
||||
_reload,
|
||||
) { q, _ -> q }
|
||||
.mapLatest { q ->
|
||||
if (q.length < MIN_QUERY_LENGTH) {
|
||||
SearchUiState.Idle
|
||||
} else {
|
||||
val results = repository.searchEvents(q)
|
||||
if (results.isEmpty()) SearchUiState.Empty(q)
|
||||
else SearchUiState.Results(sortNearestFirst(results))
|
||||
val now = Clock.System.now()
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val results = EventSearch.search(
|
||||
candidates = repository.searchEvents(q),
|
||||
query = q,
|
||||
now = now,
|
||||
todayStart = now.toLocalDateTime(zone).date.atStartOfDayIn(zone),
|
||||
zone = zone,
|
||||
)
|
||||
if (results.isEmpty) {
|
||||
SearchUiState.Empty(q)
|
||||
} else {
|
||||
SearchUiState.Results(
|
||||
results = results,
|
||||
readOnlyCalendarIds = readOnlyCalendarIds(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.catch { emit(SearchUiState.Idle) }
|
||||
@@ -65,13 +123,94 @@ class SearchViewModel @Inject constructor(
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
||||
|
||||
fun setQuery(value: String) {
|
||||
if (value != _query.value) _selection.value = emptySet()
|
||||
_query.value = value
|
||||
}
|
||||
|
||||
/** Soonest upcoming (and ongoing) first, then the most recent past. */
|
||||
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
|
||||
val now = Clock.System.now()
|
||||
val (upcoming, past) = events.partition { it.end >= now }
|
||||
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
|
||||
/** Toggle one result; the first pick is what turns selection mode on. */
|
||||
fun toggleSelection(eventId: Long) {
|
||||
val current = _selection.value
|
||||
_selection.value = if (eventId in current) current - eventId else current + eventId
|
||||
}
|
||||
|
||||
/** Pick every result that lives in a writable calendar. */
|
||||
fun selectAll() {
|
||||
val results = state.value as? SearchUiState.Results ?: return
|
||||
_selection.value = results.events.filter(results::isDeletable).map { it.eventId }.toSet()
|
||||
}
|
||||
|
||||
/** Leave selection mode. */
|
||||
fun clearSelection() {
|
||||
_selection.value = emptySet()
|
||||
}
|
||||
|
||||
/** Whether the batch needs a [RecurringWriteScope] decision before it runs. */
|
||||
fun selectionHasRecurring(): Boolean {
|
||||
val results = state.value as? SearchUiState.Results ?: return false
|
||||
val picked = _selection.value
|
||||
return results.events.any { it.eventId in picked && it.isRecurring }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every selected event. [scope] reaches the recurring ones only; a
|
||||
* one-off always goes whole. One at a time, so a single failure doesn't take
|
||||
* the rest with it — the tally lands in [deleteState].
|
||||
*/
|
||||
fun deleteSelected(scope: RecurringWriteScope) {
|
||||
if (_deleteState.value == BulkDeleteUiState.Deleting) return
|
||||
val results = state.value as? SearchUiState.Results ?: return
|
||||
val picked = _selection.value
|
||||
val targets = results.events.filter { it.eventId in picked && results.isDeletable(it) }
|
||||
if (targets.isEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_deleteState.value = BulkDeleteUiState.Deleting
|
||||
var deleted = 0
|
||||
var failed = 0
|
||||
var denied = false
|
||||
for (event in targets) {
|
||||
try {
|
||||
withContext(io) { deleteOne(event, scope) }
|
||||
deleted++
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
denied = true
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
_selection.value = emptySet()
|
||||
_reload.value += 1
|
||||
_deleteState.value = if (denied) {
|
||||
BulkDeleteUiState.NeedsPermission(deleted = deleted)
|
||||
} else {
|
||||
BulkDeleteUiState.Done(deleted = deleted, failed = failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset [deleteState] after the screen showed the outcome. */
|
||||
fun consumeDeleteResult() {
|
||||
_deleteState.value = BulkDeleteUiState.Idle
|
||||
}
|
||||
|
||||
private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) {
|
||||
val begin = event.start.toEpochMilliseconds()
|
||||
when {
|
||||
!event.isRecurring -> repository.deleteEvent(event.eventId)
|
||||
scope == RecurringWriteScope.ThisEvent ->
|
||||
repository.deleteOccurrence(event.eventId, begin)
|
||||
scope == RecurringWriteScope.ThisAndFollowing ->
|
||||
repository.deleteEventFromOccurrence(event.eventId, begin)
|
||||
else -> repository.deleteEvent(event.eventId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readOnlyCalendarIds(): Set<Long> =
|
||||
repository.calendars().first()
|
||||
.filterNot { it.canModifyContents }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||
@@ -27,7 +16,6 @@ import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
|
||||
/**
|
||||
@@ -51,39 +39,21 @@ internal fun MonthViewStylePicker(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val options = MonthViewStyle.entries
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_month_view_style),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.height(PREVIEW_HEIGHT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = selected,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = "month-style-preview",
|
||||
) { shown ->
|
||||
// The preview renders the real grid at phone size and scales it
|
||||
// down, so its own corners are square — the frame rounds it.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PREVIEW_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
MonthStylePreview(
|
||||
style = shown,
|
||||
weekStart = weekStart,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
}
|
||||
ViewPreviewFrame(
|
||||
selected = selected,
|
||||
label = "month-style-preview",
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
) { shown ->
|
||||
MonthStylePreview(
|
||||
style = shown,
|
||||
weekStart = weekStart,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
// The selected style's own blurb lives here, under its preview, rather
|
||||
// than on every row — the rows stay single-line and dense, and the words
|
||||
@@ -109,4 +79,3 @@ internal fun MonthViewStylePicker(
|
||||
}
|
||||
|
||||
private val PREVIEW_HEIGHT = 280.dp
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/**
|
||||
@@ -46,38 +34,22 @@ internal fun WeekStartPicker(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val resolved = selected.resolveFirstDay(locale)
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.height(PREVIEW_HEIGHT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = resolved,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = "week-start-preview",
|
||||
) { day ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PREVIEW_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
MonthStylePreview(
|
||||
style = monthStyle,
|
||||
weekStart = day,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
}
|
||||
ViewPreviewFrame(
|
||||
selected = resolved,
|
||||
label = "week-start-preview",
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
) { day ->
|
||||
MonthStylePreview(
|
||||
style = monthStyle,
|
||||
weekStart = day,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
PickerDescription(stringResource(R.string.settings_week_start_hint))
|
||||
options.forEachIndexed { index, option ->
|
||||
@@ -107,4 +79,3 @@ internal fun WeekStartPicker(
|
||||
|
||||
/** Short enough to leave the first options on screen under it. */
|
||||
private val PREVIEW_HEIGHT = 200.dp
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
|
||||
@@ -52,6 +52,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -128,6 +129,7 @@ import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
@@ -148,7 +150,7 @@ private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
private val COLUMN_GAP = 2.dp
|
||||
|
||||
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
||||
private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||
internal fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||
if (allDaySpans.isEmpty()) return 0.dp
|
||||
val lanes = allDaySpans.maxOf { it.lane } + 1
|
||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||
@@ -402,7 +404,7 @@ private fun WeekContent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekSuccess(
|
||||
internal fun WeekSuccess(
|
||||
state: WeekUiState.Success,
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
@@ -771,6 +773,12 @@ private fun DayColumnCard(
|
||||
LaunchedEffect(blocks, dragController.settling) {
|
||||
dragController.noteGrid(date, blocks)
|
||||
}
|
||||
// The tap detector outlives the composition that installed it — a pinch or a
|
||||
// Settings change moves the hour height without restarting it — so it reads
|
||||
// the height and the callback through state handles instead of capturing
|
||||
// them, or taps land on the scale the column had before the zoom (#148).
|
||||
val currentHourPx = rememberUpdatedState(hourPx)
|
||||
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||
Card(
|
||||
// Plain rectangular columns — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -790,8 +798,10 @@ private fun DayColumnCard(
|
||||
// blocks are consumed by their own handler first. Snaps to hour.
|
||||
.pointerInput(date) {
|
||||
detectTapGestures { offset ->
|
||||
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
||||
onCreateAt(date, hour * 60)
|
||||
currentOnCreateAt.value(
|
||||
date,
|
||||
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* A live, scaled-down Week view for the onboarding view chooser. The week and
|
||||
* today's column are real; only the events are stand-ins.
|
||||
*
|
||||
* The timeline is scrolled to the working day rather than to midnight — the
|
||||
* live view centres on noon once it knows its own scroll range, which a preview
|
||||
* that is never interacted with would otherwise never do.
|
||||
*/
|
||||
@Composable
|
||||
internal fun WeekViewPreview(
|
||||
weekStart: DayOfWeek,
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val state = remember(today, weekStart, zone) { sampleWeekState(today, weekStart, zone) }
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
// Half the scroll range is noon — the same centring the live view does.
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
WeekSuccess(
|
||||
state = state,
|
||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = state.allDayStripHeight(),
|
||||
dragController = rememberTimelineDragController(),
|
||||
onEventClick = {},
|
||||
onOpenDay = {},
|
||||
onCreateAt = { _, _ -> },
|
||||
onDrop = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample week state, laid out through the same helpers the live view uses. */
|
||||
private fun sampleWeekState(
|
||||
today: LocalDate,
|
||||
weekStart: DayOfWeek,
|
||||
zone: TimeZone,
|
||||
): WeekUiState.Success {
|
||||
val start = today.startOfWeek(weekStart)
|
||||
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
||||
val events = sampleTimelineEvents(days, today, zone)
|
||||
return WeekUiState.Success(
|
||||
weekStart = start,
|
||||
today = today,
|
||||
days = days,
|
||||
allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
||||
timedByDay = days.associateWith { layoutDay(events, it, zone) },
|
||||
)
|
||||
}
|
||||
@@ -491,4 +491,5 @@
|
||||
<item quantity="many">Importando %d eventos</item>
|
||||
<item quantity="other">Importando %d eventos</item>
|
||||
</plurals>
|
||||
<string name="settings_soften_colors">Armoniza los colores del calendario</string>
|
||||
</resources>
|
||||
|
||||
8
app/src/main/res/values-night/themes.xml
Normal file
8
app/src/main/res/values-night/themes.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<resources>
|
||||
<!-- Dark backdrop needs light status-bar icons; the base theme's `true` is
|
||||
only right for the light window background. -->
|
||||
<style name="Theme.Calendula" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
9
app/src/main/res/values-pt-rBR/strings.xml
Normal file
9
app/src/main/res/values-pt-rBR/strings.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -18,16 +18,20 @@
|
||||
|
||||
<!-- Permission flow (F1) -->
|
||||
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s all it asks for up front — and nothing ever leaves your device.</string>
|
||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events.</string>
|
||||
<string name="permission_request_button">Grant calendar access</string>
|
||||
<string name="permission_denied_title">Calendar access denied</string>
|
||||
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
||||
<string name="permission_open_settings_button">Open system settings</string>
|
||||
<string name="permission_retry_button">Try again</string>
|
||||
<string name="permission_benefit_private_title">Stays on your device</string>
|
||||
<string name="permission_benefit_private_body">Your calendars are read locally and never leave the phone.</string>
|
||||
<string name="permission_benefit_private_title">Private by design</string>
|
||||
<string name="permission_benefit_private_body">No internet permission, no telemetry — nothing ever leaves your phone.</string>
|
||||
<string name="permission_benefit_sync_title">All your calendars, together</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, local — anything synced to the device just appears.</string>
|
||||
<!-- Unused since the wizard trim (#163) — the privacy point is now
|
||||
permission_benefit_private_*. Kept until Weblate drops them from the
|
||||
translations: removing a base key a translation still carries is a
|
||||
fatal ExtraTranslation. -->
|
||||
<string name="permission_benefit_privacy_title">No tracking, ever</string>
|
||||
<string name="permission_benefit_privacy_body">Zero telemetry, zero analytics, no ads.</string>
|
||||
<string name="permission_privacy_footnote">Stays on your device · no internet permission</string>
|
||||
@@ -268,6 +272,25 @@
|
||||
<!-- Shared event strings -->
|
||||
<string name="event_untitled">(No title)</string>
|
||||
|
||||
<!-- First-launch wizard (#163) -->
|
||||
<string name="onboarding_step_counter">Step %1$d of %2$d</string>
|
||||
<string name="onboarding_backup_title">Your events live only here</string>
|
||||
<string name="onboarding_backup_body">Nothing on this phone syncs to an account, so losing it would lose your calendar. Calendula can write a backup for you.</string>
|
||||
<string name="onboarding_backup_benefit_folder_title">A folder you choose</string>
|
||||
<string name="onboarding_backup_benefit_folder_body">Backups are plain .ics files — put them somewhere that syncs, or on an SD card.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">Once a day, by itself</string>
|
||||
<string name="onboarding_backup_benefit_daily_body">Calendula exports your local calendars in the background. Change how often in Settings.</string>
|
||||
<string name="onboarding_backup_enable_button">Choose folder and back up</string>
|
||||
<string name="onboarding_backup_skip_button">Not now</string>
|
||||
<string name="onboarding_view_title">What should open first?</string>
|
||||
<string name="onboarding_month_style_title">How should Month look?</string>
|
||||
<string name="onboarding_view_continue_button">Continue</string>
|
||||
<string name="onboarding_back">Back</string>
|
||||
<string name="onboarding_visibility_button">Got it</string>
|
||||
<string name="onboarding_done_title">You\'re all set</string>
|
||||
<string name="onboarding_done_body">Your calendars are ready. Everything you just chose can be changed later in Settings.</string>
|
||||
<string name="onboarding_done_button">Open my calendar</string>
|
||||
|
||||
<!-- Reminder notifications (v1.4) -->
|
||||
<string name="reminder_channel_name">Event reminders</string>
|
||||
<string name="reminder_channel_description">Notifications at the reminder times of your events</string>
|
||||
@@ -277,6 +300,7 @@
|
||||
<string name="reminder_benefit_delivery_body">Every reminder on your events arrives as a notification, right on time.</string>
|
||||
<string name="reminder_benefit_duplicates_title">Using a second calendar app?</string>
|
||||
<string name="reminder_benefit_duplicates_body">If another app also posts reminders, you\'ll see them twice — turn them off there or here.</string>
|
||||
<!-- Unused since the wizard trim (#163); see the note above. -->
|
||||
<string name="reminder_benefit_reversible_title">Change it anytime</string>
|
||||
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
||||
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
||||
@@ -318,6 +342,26 @@
|
||||
<string name="search_idle_hint">Search your events by title, location or notes.</string>
|
||||
<string name="search_empty">No events match “%1$s”.</string>
|
||||
|
||||
<!-- Selecting search results to delete several at once (#80) -->
|
||||
<plurals name="search_selected_count">
|
||||
<item quantity="one">%d selected</item>
|
||||
<item quantity="other">%d selected</item>
|
||||
</plurals>
|
||||
<string name="search_in_descriptions">Found in descriptions</string>
|
||||
<string name="search_selection_close">Cancel selection</string>
|
||||
<string name="search_select_all">Select all</string>
|
||||
<string name="search_delete_selected">Delete selected</string>
|
||||
<plurals name="search_delete_title">
|
||||
<item quantity="one">Delete %d event?</item>
|
||||
<item quantity="other">Delete %d events?</item>
|
||||
</plurals>
|
||||
<plurals name="search_delete_done">
|
||||
<item quantity="one">%d event deleted</item>
|
||||
<item quantity="other">%d events deleted</item>
|
||||
</plurals>
|
||||
<string name="search_delete_partial">%1$d deleted, %2$d couldn\'t be</string>
|
||||
<string name="search_delete_denied_partial">%1$d deleted, then write access was withdrawn</string>
|
||||
|
||||
<!-- Home-screen widgets -->
|
||||
<string name="widget_agenda_title">Upcoming</string>
|
||||
<string name="widget_agenda_label">Calendula agenda</string>
|
||||
@@ -577,7 +621,7 @@
|
||||
<string name="calendars_visibility_hint">Turn a calendar off to hide it on this device — its events disappear from the app and it stops reminding you. This is the same switch your other calendar apps use, so they hide it too. Nothing is deleted, no other device is affected, and you can turn it back on here anytime.</string>
|
||||
<string name="calendars_visibility_a11y">Show \"%1$s\"</string>
|
||||
<string name="calendars_visibility_notice_title">Some calendars are switched off</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula now shows the calendars that are switched on for this device, so what you see and what reminds you can no longer disagree. Some of yours are currently off — they were switched off here or in another calendar app. Turn any of them back on in Settings → Calendars.</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula shows the calendars that are switched on for this device, and some of yours are off. To see their events, switch them on under Settings → Calendars.</string>
|
||||
<!-- Footer row under the event-form and .ics import calendar pickers. -->
|
||||
<string name="calendar_picker_missing_title">Missing a calendar?</string>
|
||||
<string name="calendar_picker_missing_summary">It may be switched off, read-only, or filled from your contacts — manage your calendars here.</string>
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
Compose theme; this is essentially the launch/backdrop theme. -->
|
||||
<style name="Theme.Calendula" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -451,15 +452,15 @@ class CalendarRepositoryImplTest {
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L))
|
||||
searchResult = {
|
||||
listOf(
|
||||
makeEvent(10L, "Shown", calendarId = 1L),
|
||||
makeEvent(11L, "Switched off", calendarId = 2L),
|
||||
makeEvent(12L, "Hidden", calendarId = 3L),
|
||||
SearchCandidate(makeEvent(10L, "Shown", calendarId = 1L)),
|
||||
SearchCandidate(makeEvent(11L, "Switched off", calendarId = 2L)),
|
||||
SearchCandidate(makeEvent(12L, "Hidden", calendarId = 3L)),
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
assertThat(repo.searchEvents("e").map { it.title }).containsExactly("Shown")
|
||||
assertThat(repo.searchEvents("e").map { it.event.title }).containsExactly("Shown")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
@@ -19,7 +20,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
|
||||
var calendarsResult: List<CalendarSource> = emptyList()
|
||||
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||
var searchResult: (String) -> List<EventInstance> = { _ -> emptyList() }
|
||||
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
|
||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||
var exportableEventsResult: List<IcsEvent> = emptyList()
|
||||
@@ -29,6 +30,10 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
var existingUidsResult: Set<String> = emptySet()
|
||||
/** Set to make the next write call throw. */
|
||||
var writeError: Exception? = null
|
||||
|
||||
/** Deletes let through before [writeError] applies, for part-way failures. */
|
||||
var deletesBeforeError: Int = 0
|
||||
private var deletes = 0
|
||||
/** Id returned by the next [insertEvent]. */
|
||||
var nextInsertId: Long = 100L
|
||||
|
||||
@@ -71,7 +76,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
}
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||
instancesResult(beginMillis, endMillis)
|
||||
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
|
||||
override fun searchEvents(query: String): List<SearchCandidate> = searchResult(query)
|
||||
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? =
|
||||
eventDetailResult(eventId)
|
||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
@@ -196,7 +201,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
}
|
||||
|
||||
override fun deleteEvent(eventId: Long) {
|
||||
writeError?.let { throw it }
|
||||
writeError?.let { if (deletes++ >= deletesBeforeError) throw it }
|
||||
deletedEventIds += eventId
|
||||
managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } }
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ class SearchMapperTest {
|
||||
eventColor: Any? = null,
|
||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||
location: String? = null,
|
||||
rrule: String? = null,
|
||||
rdate: String? = null,
|
||||
): MapColumnReader = MapColumnReader(
|
||||
SearchProjection.IDX_RRULE to rrule,
|
||||
SearchProjection.IDX_RDATE to rdate,
|
||||
SearchProjection.IDX_ID to id,
|
||||
SearchProjection.IDX_CALENDAR_ID to calendarId,
|
||||
SearchProjection.IDX_TITLE to title,
|
||||
@@ -42,4 +46,16 @@ class SearchMapperTest {
|
||||
fun `absent dtstart drops the search hit`() {
|
||||
assertThat(searchReader(dtstart = null).toSearchResult()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rule or an rdate marks the hit recurring (issue #80)`() {
|
||||
assertThat(searchReader().toSearchResult()!!.isRecurring).isFalse()
|
||||
assertThat(searchReader(rrule = "").toSearchResult()!!.isRecurring).isFalse()
|
||||
assertThat(
|
||||
searchReader(rrule = "FREQ=WEEKLY").toSearchResult()!!.isRecurring,
|
||||
).isTrue()
|
||||
assertThat(
|
||||
searchReader(rdate = "20260101T000000Z").toSearchResult()!!.isRecurring,
|
||||
).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +337,32 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wizard arms on a first run`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.armOnboardingWizard()
|
||||
assertThat(prefs.onboardingWizardArmed.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wizard does not arm once the reminder step is answered`(@TempDir tempDir: Path) = runTest {
|
||||
// An existing install re-granting the permission is not re-onboarded.
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setReminderOnboardingDone()
|
||||
prefs.armOnboardingWizard()
|
||||
assertThat(prefs.onboardingWizardArmed.first()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `closing the wizard disarms it`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.armOnboardingWizard()
|
||||
|
||||
prefs.finishOnboardingWizard()
|
||||
assertThat(prefs.onboardingDoneShown.first()).isTrue()
|
||||
assertThat(prefs.onboardingWizardArmed.first()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -126,4 +126,24 @@ class TimelineScaleTest {
|
||||
assertThat(parseTimelineScale(stored)).isEqualTo(TimelineScale.Regular)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap creates at the hour it landed in, at whatever scale`() {
|
||||
// Same point on the same column reads as a different hour once the
|
||||
// timeline has been pinched — which is the whole of #148: the tap has to
|
||||
// be measured against the height the column is drawing at now.
|
||||
assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 100f)).isEqualTo(5 * 60)
|
||||
assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 50f)).isEqualTo(10 * 60)
|
||||
// Within an hour it snaps back to that hour's start.
|
||||
assertThat(tappedMinuteOfDay(offsetY = 599f, hourPx = 100f)).isEqualTo(5 * 60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap outside the day stays inside it`() {
|
||||
assertThat(tappedMinuteOfDay(offsetY = -20f, hourPx = 56f)).isEqualTo(0)
|
||||
assertThat(tappedMinuteOfDay(offsetY = 99_999f, hourPx = 56f)).isEqualTo(23 * 60)
|
||||
// A viewport measured at nothing has no grid to read — midnight, not a
|
||||
// division by zero.
|
||||
assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 0f)).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The wizard's step plan (#163). Two invariants carry the whole feature: an
|
||||
* existing install must never be re-onboarded, and the counter must not
|
||||
* renumber under the user as steps are completed.
|
||||
*/
|
||||
class OnboardingPlanTest {
|
||||
|
||||
private fun plan(
|
||||
hasPermission: Boolean = false,
|
||||
remindersDone: Boolean = false,
|
||||
wizardArmed: Boolean = false,
|
||||
backupDone: Boolean = false,
|
||||
viewDone: Boolean = false,
|
||||
monthStyleDone: Boolean = false,
|
||||
backupApplies: Boolean? = null,
|
||||
visibilityArmed: Boolean = false,
|
||||
visibilityDone: Boolean = false,
|
||||
doneShown: Boolean = false,
|
||||
) = onboardingPlan(
|
||||
hasPermission = hasPermission,
|
||||
remindersDone = remindersDone,
|
||||
wizardArmed = wizardArmed,
|
||||
backupDone = backupDone,
|
||||
viewDone = viewDone,
|
||||
monthStyleDone = monthStyleDone,
|
||||
backupApplies = backupApplies,
|
||||
visibilityArmed = visibilityArmed,
|
||||
visibilityDone = visibilityDone,
|
||||
doneShown = doneShown,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fresh install starts on the permission step`() {
|
||||
val fresh = plan()
|
||||
assertThat(fresh.current).isEqualTo(OnboardingStep.Permission)
|
||||
assertThat(fresh.index).isEqualTo(1)
|
||||
assertThat(fresh.showsProgress).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `granting the permission does not renumber the steps behind it`() {
|
||||
// The plan keeps completed steps, so the reminder step stays step 2 of
|
||||
// the same flow rather than becoming step 1 of a shorter one.
|
||||
val granted = plan(hasPermission = true, wizardArmed = true, backupApplies = true)
|
||||
assertThat(granted.steps).containsExactly(
|
||||
OnboardingStep.Permission,
|
||||
OnboardingStep.Reminders,
|
||||
OnboardingStep.Backup,
|
||||
OnboardingStep.View,
|
||||
OnboardingStep.MonthStyle,
|
||||
OnboardingStep.Done,
|
||||
).inOrder()
|
||||
assertThat(granted.current).isEqualTo(OnboardingStep.Reminders)
|
||||
assertThat(granted.index).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a synced calendar drops the backup step`() {
|
||||
val synced = plan(
|
||||
hasPermission = true,
|
||||
wizardArmed = true,
|
||||
remindersDone = true,
|
||||
backupApplies = false,
|
||||
)
|
||||
assertThat(synced.steps).doesNotContain(OnboardingStep.Backup)
|
||||
assertThat(synced.current).isEqualTo(OnboardingStep.View)
|
||||
assertThat(synced.total).isEqualTo(5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the backup step is assumed until the calendars can be read`() {
|
||||
// Before the grant the answer is unknowable, so the flow is planned at
|
||||
// its longest — it may shrink afterwards, never grow.
|
||||
assertThat(plan(backupApplies = null).steps).contains(OnboardingStep.Backup)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an existing install is not re-onboarded`() {
|
||||
val existing = plan(hasPermission = true, remindersDone = true)
|
||||
assertThat(existing.steps).isEmpty()
|
||||
assertThat(existing.current).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an existing install owing only the reminder step gets no counter`() {
|
||||
val existing = plan(hasPermission = true)
|
||||
assertThat(existing.steps).containsExactly(OnboardingStep.Reminders)
|
||||
assertThat(existing.current).isEqualTo(OnboardingStep.Reminders)
|
||||
assertThat(existing.showsProgress).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-granting the permission on an onboarded install skips the extra steps`() {
|
||||
// Revoked and granted again: the reminder step is already answered, so
|
||||
// this is not a fresh install and only the permission is owed.
|
||||
val revoked = plan(remindersDone = true)
|
||||
assertThat(revoked.steps).containsExactly(OnboardingStep.Permission)
|
||||
assertThat(revoked.showsProgress).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-granting after the wizard itself ran does not re-enter it`() {
|
||||
// The closing screen clears the armed flag, so someone the wizard did
|
||||
// onboard ends up where any other revoker does: one screen, no counter.
|
||||
val revoked = plan(remindersDone = true, wizardArmed = false, doneShown = true)
|
||||
assertThat(revoked.steps).containsExactly(OnboardingStep.Permission)
|
||||
assertThat(revoked.showsProgress).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read notice leaves nothing behind in a later plan`() {
|
||||
// The notice is retired once read, so its step stops padding the count
|
||||
// on a later re-grant.
|
||||
val later = plan(remindersDone = true, visibilityArmed = false, visibilityDone = true)
|
||||
assertThat(later.steps).containsExactly(OnboardingStep.Permission)
|
||||
assertThat(later.showsProgress).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `answering every step ends the flow`() {
|
||||
val done = plan(
|
||||
hasPermission = true,
|
||||
remindersDone = true,
|
||||
wizardArmed = true,
|
||||
backupDone = true,
|
||||
viewDone = true,
|
||||
monthStyleDone = true,
|
||||
backupApplies = true,
|
||||
doneShown = true,
|
||||
)
|
||||
assertThat(done.current).isNull()
|
||||
assertThat(done.index).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the month style is asked whatever view was chosen`() {
|
||||
// Month is reachable from the drawer whatever opens first, and making
|
||||
// the step conditional would move the counter on the step before it.
|
||||
val afterView = plan(
|
||||
hasPermission = true,
|
||||
wizardArmed = true,
|
||||
remindersDone = true,
|
||||
backupDone = true,
|
||||
viewDone = true,
|
||||
backupApplies = true,
|
||||
)
|
||||
assertThat(afterView.current).isEqualTo(OnboardingStep.MonthStyle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back steps to the previous answered step`() {
|
||||
val onView = plan(
|
||||
hasPermission = true,
|
||||
wizardArmed = true,
|
||||
remindersDone = true,
|
||||
backupDone = true,
|
||||
backupApplies = true,
|
||||
)
|
||||
assertThat(onView.current).isEqualTo(OnboardingStep.View)
|
||||
assertThat(onView.previous).isEqualTo(OnboardingStep.Backup)
|
||||
assertThat(onView.canGoBack).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back is refused where the previous step is the system grant`() {
|
||||
// The permission is the system's to give; there is nothing to return to.
|
||||
val onReminders = plan(hasPermission = true, wizardArmed = true, backupApplies = true)
|
||||
assertThat(onReminders.previous).isEqualTo(OnboardingStep.Permission)
|
||||
assertThat(onReminders.canGoBack).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back is refused on the first step and once the flow is done`() {
|
||||
assertThat(plan().canGoBack).isFalse()
|
||||
val done = plan(
|
||||
hasPermission = true,
|
||||
remindersDone = true,
|
||||
wizardArmed = true,
|
||||
backupDone = true,
|
||||
viewDone = true,
|
||||
monthStyleDone = true,
|
||||
backupApplies = true,
|
||||
doneShown = true,
|
||||
)
|
||||
assertThat(done.previous).isNull()
|
||||
assertThat(done.canGoBack).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wizard ends on its closing screen`() {
|
||||
val lastAnswer = plan(
|
||||
hasPermission = true,
|
||||
remindersDone = true,
|
||||
wizardArmed = true,
|
||||
backupDone = true,
|
||||
viewDone = true,
|
||||
monthStyleDone = true,
|
||||
backupApplies = true,
|
||||
)
|
||||
assertThat(lastAnswer.current).isEqualTo(OnboardingStep.Done)
|
||||
assertThat(lastAnswer.index).isEqualTo(lastAnswer.total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the visibility notice is asked before the closing screen`() {
|
||||
val armed = plan(
|
||||
hasPermission = true,
|
||||
remindersDone = true,
|
||||
wizardArmed = true,
|
||||
backupDone = true,
|
||||
viewDone = true,
|
||||
monthStyleDone = true,
|
||||
backupApplies = true,
|
||||
visibilityArmed = true,
|
||||
)
|
||||
assertThat(armed.current).isEqualTo(OnboardingStep.Visibility)
|
||||
assertThat(armed.steps.last()).isEqualTo(OnboardingStep.Done)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an onboarded install owing only the notice gets one screen and no counter`() {
|
||||
// The notice is the one step an existing install can still be given —
|
||||
// and on its own it is not a wizard, so there is nothing to conclude.
|
||||
val notice = plan(hasPermission = true, remindersDone = true, visibilityArmed = true)
|
||||
assertThat(notice.steps).containsExactly(OnboardingStep.Visibility)
|
||||
assertThat(notice.current).isEqualTo(OnboardingStep.Visibility)
|
||||
assertThat(notice.showsProgress).isFalse()
|
||||
assertThat(notice.canGoBack).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading the notice ends an otherwise onboarded flow`() {
|
||||
val read = plan(
|
||||
hasPermission = true,
|
||||
remindersDone = true,
|
||||
visibilityArmed = true,
|
||||
visibilityDone = true,
|
||||
)
|
||||
assertThat(read.current).isNull()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ the package list (recurring writes, save conflicts, reminder delivery).
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph UI ["ui/ — Compose screens + ViewModels"]
|
||||
Screens["Month / Week / Day\nDetail / Edit / Settings\nPermission + Reminder onboarding"]
|
||||
Screens["Month / Week / Day\nDetail / Edit / Settings\nOnboarding wizard"]
|
||||
end
|
||||
subgraph Data ["data/"]
|
||||
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
|
||||
@@ -78,8 +78,8 @@ flowchart TD
|
||||
## Navigation
|
||||
|
||||
There is no navigation library. `MainActivity` hosts `RootScreen`, which
|
||||
gates on the calendar permission and the one-time reminder onboarding, then
|
||||
shows `CalendarHost`. `CalendarHost` holds the active view (month/week/day)
|
||||
gates on the first-launch wizard (`ui/onboarding/`), then shows
|
||||
`CalendarHost`. `CalendarHost` holds the active view (month/week/day)
|
||||
plus overlay state for detail, edit, and settings — full-screen overlays
|
||||
driven by `AnimatedVisibility` with a *held-key* pattern: the last shown
|
||||
key stays alive through the slide-out so content never flashes empty.
|
||||
@@ -87,6 +87,32 @@ A tapped reminder notification routes through `MainActivity` (`singleTop` +
|
||||
`onNewIntent`) as an external detail key that `CalendarHost` consumes
|
||||
exactly like an event tap.
|
||||
|
||||
### First-launch wizard
|
||||
|
||||
`ui/onboarding/` is a plan, not a navigation graph: `onboardingPlan()` is a
|
||||
pure function of the stored answers that returns the whole flow *plus* the
|
||||
step it is on, and `RootScreen` renders that step. Two rules make it work:
|
||||
|
||||
- The plan keeps **completed** steps, so finishing one never renumbers the
|
||||
counter under the user, and stepping *back* is just clearing the previous
|
||||
step's answer — there is no separate back stack. The calendar grant is the
|
||||
one step with nothing to return to, so back stops there.
|
||||
- The optional steps (backup, view) only exist for an install still on its
|
||||
first run — `armOnboardingWizard()` arms on any launch holding the calendar
|
||||
permission but refuses if the reminder step was already answered, and the
|
||||
closing screen clears the flag again, so revoking and re-granting the
|
||||
permission later never re-onboards anyone.
|
||||
|
||||
Whether the backup step applies (nothing writable is synced anywhere) cannot
|
||||
be known before the grant, so it is assumed until the calendar list can be
|
||||
read: the flow may shrink after the grant, never sprout a step the counter
|
||||
had not accounted for.
|
||||
|
||||
The flow closes on an "all set" screen, and the one-time calendar-visibility
|
||||
notice (#75) is a step in front of it rather than a dialog over the app — it
|
||||
is the only optional step an *already onboarded* install can be given, which
|
||||
is why it alone is not gated on the wizard being armed.
|
||||
|
||||
## Recurring writes
|
||||
|
||||
The provider's invariants drive the design (learned the hard way, verified
|
||||
|
||||
2
fastlane/metadata/android/ar/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/ar/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
إصلاحات
|
||||
• عرض الأسبوع واليوم: النقر على مساحة فارغة يُنشئ الحدث في الساعة التي نقرت عليها، حتى بعد تكبير شريط الوقت أو تغيير ارتفاع الساعة.
|
||||
6
fastlane/metadata/android/ar/changelogs/21900.txt
Normal file
6
fastlane/metadata/android/ar/changelogs/21900.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
جديد
|
||||
• اسحب الحدث لتغيير وقته أو يومه: اضغط عليه مطولاً في عرض الشهر أو الأسبوع أو اليوم ثم أفلته في المكان الجديد. في الأحداث المتكررة يُسأل عن نطاق النقل.
|
||||
• احذف عدة نتائج بحث دفعة واحدة: اضغط مطولاً على نتيجة لبدء التحديد، ثم احذفها معاً.
|
||||
|
||||
تغييرات
|
||||
• أصبح التشغيل الأول معالجاً قصيراً. يمكنه أيضاً تفعيل النسخ الاحتياطي التلقائي واختيار العرض الافتتاحي، وكلاهما مع معاينة.
|
||||
2
fastlane/metadata/android/de-DE/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/de-DE/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Behoben
|
||||
• Wochen- und Tagesansicht: Ein Tippen auf einen freien Bereich legt den Termin jetzt zur angetippten Uhrzeit an – auch nachdem die Zeitleiste gezoomt oder die Stundenhöhe geändert wurde.
|
||||
6
fastlane/metadata/android/de-DE/changelogs/21900.txt
Normal file
6
fastlane/metadata/android/de-DE/changelogs/21900.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Neu
|
||||
• Termine per Drag & Drop verschieben: in der Monats-, Wochen- oder Tagesansicht lange gedrückt halten und zu einer anderen Zeit oder einem anderen Tag ablegen. Bei Serienterminen wird nachgefragt, wofür sie gilt.
|
||||
• Mehrere Suchergebnisse auf einmal löschen: langer Druck auf einen Treffer startet die Auswahl.
|
||||
|
||||
Geändert
|
||||
• Der erste Start ist jetzt ein kurzer Assistent. Er kann auch die automatische Sicherung einrichten und dich die Startansicht wählen lassen – beides mit Vorschau.
|
||||
2
fastlane/metadata/android/en-GB/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/en-GB/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Fixed
|
||||
• Week and day view: tapping an empty slot now creates the event at the hour you tapped, even after zooming the timeline or changing the hour height.
|
||||
6
fastlane/metadata/android/en-GB/changelogs/21900.txt
Normal file
6
fastlane/metadata/android/en-GB/changelogs/21900.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
New
|
||||
• Drag an event to reschedule it — press and hold it in month, week or day view and drop it on another time or day. Recurring events ask what the move applies to.
|
||||
• Delete several search results at once: long-press a hit to start selecting, then delete them in one action.
|
||||
|
||||
Changed
|
||||
• First launch is now a short wizard. It can also set up automatic backup and let you pick the view Calendula opens on, both with previews.
|
||||
2
fastlane/metadata/android/en-US/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Fixed
|
||||
• Week and day view: tapping an empty slot now creates the event at the hour you tapped, even after zooming the timeline or changing the hour height.
|
||||
6
fastlane/metadata/android/en-US/changelogs/21900.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/21900.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
New
|
||||
• Drag an event to reschedule it — press and hold it in month, week or day view and drop it on another time or day. Recurring events ask what the move applies to.
|
||||
• Delete several search results at once: long-press a hit to start selecting, then delete them in one action.
|
||||
|
||||
Changed
|
||||
• First launch is now a short wizard. It can also set up automatic backup and let you pick the view Calendula opens on, both with previews.
|
||||
2
fastlane/metadata/android/es-ES/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/es-ES/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Corregido
|
||||
• Vista de semana y día: al tocar un hueco libre, el evento se crea a la hora que has tocado, incluso después de ampliar la línea de tiempo o cambiar la altura de la hora.
|
||||
6
fastlane/metadata/android/es-ES/changelogs/21900.txt
Normal file
6
fastlane/metadata/android/es-ES/changelogs/21900.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Novedades
|
||||
• Arrastra un evento para cambiarlo de hora o de día: mantenlo pulsado en la vista de mes, semana o día y suéltalo donde quieras. En los eventos periódicos se pregunta a qué afecta el cambio.
|
||||
• Elimina varios resultados de búsqueda a la vez: mantén pulsado un resultado para empezar a seleccionar.
|
||||
|
||||
Cambios
|
||||
• El primer inicio es ahora un breve asistente. También puede activar la copia de seguridad automática y dejarte elegir la vista inicial, ambas con vista previa.
|
||||
2
fastlane/metadata/android/fr-FR/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/fr-FR/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Corrigé
|
||||
• Vues Semaine et Jour : appuyer sur un créneau vide crée désormais l'événement à l'heure touchée, même après avoir zoomé sur la grille ou modifié la hauteur d'une heure.
|
||||
6
fastlane/metadata/android/fr-FR/changelogs/21900.txt
Normal file
6
fastlane/metadata/android/fr-FR/changelogs/21900.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Nouveautés
|
||||
• Déplacez un événement par glisser-déposer : appuyez longuement dessus en vue mois, semaine ou jour, puis déposez-le à une autre heure ou un autre jour. Pour un événement récurrent, la portée du déplacement est demandée.
|
||||
• Supprimez plusieurs résultats de recherche d'un coup : un appui long lance la sélection.
|
||||
|
||||
Modifié
|
||||
• Le premier lancement est désormais un court assistant. Il peut aussi activer la sauvegarde automatique et vous laisser choisir la vue d'ouverture, avec aperçu.
|
||||
2
fastlane/metadata/android/it-IT/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/it-IT/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Corretto
|
||||
• Vista settimana e giorno: toccando uno spazio libero l'evento viene creato all'ora toccata, anche dopo aver ingrandito la linea temporale o cambiato l'altezza dell'ora.
|
||||
6
fastlane/metadata/android/it-IT/changelogs/21900.txt
Normal file
6
fastlane/metadata/android/it-IT/changelogs/21900.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Novità
|
||||
• Trascina un evento per spostarlo a un altro orario o giorno: tienilo premuto nella vista mese, settimana o giorno e rilascialo. Per gli eventi ricorrenti viene chiesto a cosa si applica lo spostamento.
|
||||
• Elimina più risultati di ricerca in una volta: tieni premuto un risultato per iniziare a selezionare.
|
||||
|
||||
Modifiche
|
||||
• Il primo avvio è ora una breve procedura guidata. Può anche attivare il backup automatico e farti scegliere la vista iniziale, entrambi con anteprima.
|
||||
2
fastlane/metadata/android/pl-PL/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/pl-PL/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Poprawki
|
||||
• Widok tygodnia i dnia: dotknięcie pustego miejsca tworzy teraz wydarzenie o dotkniętej godzinie, także po powiększeniu osi czasu lub zmianie wysokości godziny.
|
||||
6
fastlane/metadata/android/pl-PL/changelogs/21900.txt
Normal file
6
fastlane/metadata/android/pl-PL/changelogs/21900.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Nowości
|
||||
• Przeciągnij wydarzenie, aby zmienić jego godzinę lub dzień: przytrzymaj je w widoku miesiąca, tygodnia lub dnia i upuść w nowym miejscu. Przy wydarzeniach cyklicznych pojawi się pytanie, czego dotyczy zmiana.
|
||||
• Usuwaj wiele wyników wyszukiwania naraz: przytrzymaj wynik, aby rozpocząć zaznaczanie.
|
||||
|
||||
Zmiany
|
||||
• Pierwsze uruchomienie to teraz krótki kreator. Może też włączyć automatyczną kopię zapasową i pozwolić wybrać widok startowy – oba z podglądem.
|
||||
6
fastlane/metadata/android/pt-BR/changelogs/21900.txt
Normal file
6
fastlane/metadata/android/pt-BR/changelogs/21900.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Novidades
|
||||
• Arraste um evento para outro horário ou dia: mantenha-o pressionado na visualização de mês, semana ou dia e solte-o onde quiser. Em eventos recorrentes, você escolhe a que a alteração se aplica.
|
||||
• Exclua vários resultados da busca de uma vez: mantenha um resultado pressionado para começar a selecionar.
|
||||
|
||||
Alterações
|
||||
• A primeira execução agora é um breve assistente. Ele também pode ativar o backup automático e deixar você escolher a visualização inicial, ambos com prévia.
|
||||
2
fastlane/metadata/android/pt-PT/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/pt-PT/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Corrigido
|
||||
• Vista de semana e de dia: tocar num espaço livre cria o evento à hora tocada, mesmo depois de ampliar a linha temporal ou alterar a altura da hora.
|
||||
6
fastlane/metadata/android/pt-PT/changelogs/21900.txt
Normal file
6
fastlane/metadata/android/pt-PT/changelogs/21900.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Novidades
|
||||
• Arraste um evento para outra hora ou dia: mantenha-o premido na vista de mês, semana ou dia e largue-o onde quiser. Nos eventos recorrentes, é perguntado a que se aplica a alteração.
|
||||
• Elimine vários resultados de pesquisa de uma vez: mantenha um resultado premido para começar a selecionar.
|
||||
|
||||
Alterações
|
||||
• O primeiro arranque é agora um breve assistente. Também pode ativar a cópia de segurança automática e deixá-lo escolher a vista inicial, ambas com pré-visualização.
|
||||
2
fastlane/metadata/android/ru-RU/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/ru-RU/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Исправлено
|
||||
• Вид недели и дня: нажатие на свободное место создаёт событие на выбранный час — в том числе после масштабирования шкалы времени или изменения высоты часа.
|
||||
6
fastlane/metadata/android/ru-RU/changelogs/21900.txt
Normal file
6
fastlane/metadata/android/ru-RU/changelogs/21900.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Новое
|
||||
• Перетащите событие, чтобы перенести его: удерживайте его в режиме месяца, недели или дня и отпустите на другом времени или дне. Для повторяющихся событий будет предложено выбрать, к чему применить перенос.
|
||||
• Удаляйте несколько результатов поиска сразу: удержите результат, чтобы начать выделение.
|
||||
|
||||
Изменения
|
||||
• Первый запуск теперь короткий мастер. Он также может включить автоматическое резервное копирование и дать выбрать стартовый вид — и то и другое с предпросмотром.
|
||||
2
fastlane/metadata/android/zh-CN/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/zh-CN/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
修复
|
||||
• 周视图和日视图:点按空白处会在所点的时间创建事件,即使在缩放时间轴或更改小时高度之后也是如此。
|
||||
6
fastlane/metadata/android/zh-CN/changelogs/21900.txt
Normal file
6
fastlane/metadata/android/zh-CN/changelogs/21900.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
新增
|
||||
• 拖动事件即可改期:在月、周或日视图中长按事件,拖到其他时间或日期后放开。重复事件会询问此次移动的适用范围。
|
||||
• 一次删除多条搜索结果:长按某条结果进入选择模式,然后一并删除。
|
||||
|
||||
变更
|
||||
• 首次启动改为简短的向导。它还可以帮你开启自动备份,并选择打开时显示的视图,两者均有预览。
|
||||
Submodule floret-kit updated: 71a4f371b6...05c79f2afc
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
agp = "9.2.1"
|
||||
kotlin = "2.3.21"
|
||||
ksp = "2.3.9"
|
||||
hilt = "2.59.2"
|
||||
ksp = "2.3.10"
|
||||
hilt = "2.60.1"
|
||||
coreKtx = "1.19.0"
|
||||
appcompat = "1.7.1"
|
||||
lifecycleRuntime = "2.10.0"
|
||||
@@ -11,7 +11,7 @@ composeBom = "2026.06.01"
|
||||
# Material 3 Expressive APIs currently live only in the 1.5 alpha line.
|
||||
# Pin explicitly to override the BOM (which ships stable 1.4.0).
|
||||
# Re-evaluate when 1.5.0 stable lands.
|
||||
material3 = "1.5.0-alpha24"
|
||||
material3 = "1.5.0-alpha25"
|
||||
datastore = "1.2.1"
|
||||
junit = "6.1.2"
|
||||
junitPlatform = "6.1.2"
|
||||
@@ -19,15 +19,15 @@ truth = "1.4.5"
|
||||
androidxJunit = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
kotlinxDatetime = "0.7.0"
|
||||
kotlinxCoroutines = "1.10.2"
|
||||
kotlinxCoroutines = "1.11.0"
|
||||
turbine = "1.2.1"
|
||||
hiltNavigationCompose = "1.3.0"
|
||||
hiltNavigationCompose = "1.4.0"
|
||||
lifecycleCompose = "2.10.0"
|
||||
androidxTestRules = "1.7.0"
|
||||
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
||||
glance = "1.1.1"
|
||||
work = "2.10.1"
|
||||
documentfile = "1.0.1"
|
||||
documentfile = "1.1.0"
|
||||
|
||||
[libraries]
|
||||
# AndroidX core
|
||||
|
||||
Reference in New Issue
Block a user