Compare commits

..

19 Commits

Author SHA1 Message Date
024deadf78 chore(release): prepare v2.9.0
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m5s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:56:54 +02:00
599c3c03e7 Merge pull request 'feat(ui): consistent motion polish, predictive back & reduced-motion' (!36) from feat/ui-polish-animations into release/v2.9.0
Reviewed-on: #36
2026-06-25 07:52:32 +00:00
5d48c90408 docs(changelog): note motion polish, predictive back & reduced-motion
All checks were successful
CI / ci (pull_request) Successful in 4m27s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:20:45 +02:00
c6d15d0118 feat(ui): consistent motion polish, predictive back & reduced-motion support
Centralise the app's motion vocabulary in CalendarTransitions.kt and route
every surface through it so animation is consistent app-wide:

- Shared expand/collapse, list-item and fade-through helpers; the event-edit
  expand pattern is now the shared one (no duplicate).
- Settings reminder-override rows and the reminder Custom field now expand/
  collapse instead of bare-fading.
- Search and agenda rows animate (fade/relocate) via animateItem.
- Month/week/day slide and the onboarding gates honour the new helpers.
- View switching (month/week/day/agenda) now fades through instead of
  snapping — lateral navigation per M3, while in-view paging keeps its slide.

Add full predictive-back support:

- enableOnBackInvokedCallback in the manifest.
- New Modifier.predictiveBack(onBack) drives the standard preview transform
  (scale/shift/round) following the back gesture; applied to detail, edit,
  search, settings (+ sub-screens & calendar manager via CollapsingScaffold),
  the calendar editor and import — each keeping its existing back semantics.

Reduced-motion guardrail throughout: rememberReduceMotion() (reads the OS
"remove animations" setting, which Compose ignores by default) collapses
spatial motion to a quick fade and skips the back preview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:19:35 +02:00
79e083b4d4 Merge pull request 'feat: configurable default view + widget-aware back navigation' (!35) from feat/view-back-stack into release/v2.9.0
Reviewed-on: #35
2026-06-25 06:25:15 +00:00
119a8afe8e feat(nav): interactive month widget + history-retracing back
All checks were successful
Translations / check (pull_request) Successful in 28s
CI / ci (pull_request) Successful in 4m18s
On-device review follow-ups:

- Month widget grid is now tappable. Day numbers open that day and event
  bars open the event's detail, both rooted in the month view so back
  returns to the grid. Previously only the prev/next/today header
  controls responded — the grid cells were never clickable.

- Pill/drawer view switches now build a visit history instead of
  collapsing to the default view. Back retraces the views you moved
  through (a not-yet-visited view is pushed; revisiting one collapses the
  loop back to it), down to the default, then exits. Widget launches
  still reset to their own view context.

Refs #1, #2 (reported by @devinside).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:17:23 +02:00
b537e143f8 feat(nav): default view setting + widget-aware back stack
Replace CalendarHost's single global view slot with a top-level view
back stack rooted at a user-configurable default view. A lateral move
(pill/drawer/widget) replaces the non-home top; a date tap drills the
day view on top; a base-level BackHandler pops one level until only the
home view remains, then the system exits.

Widgets now carry their source view (EXTRA_SOURCE_VIEW) so a launch
roots the stack in that widget's view: backing out of a day/event opened
from the agenda widget returns to Agenda, and from the month widget to
Month, instead of always landing on Week. Reminder taps keep the
separate detail-key channel and leave the base view untouched.

Add a Default view setting (Settings -> Appearance) backing the stack's
home view; defaults to Week so existing users see no change.

Resolves the two Codeberg reports from @devinside:
- #1 [FR] Option to set default view
- #2 [Bug] Widget UX improvement

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:07:00 +02:00
faf2f27eda Merge pull request 'release: v2.8.0' (!34) from release/v2.8.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 30s
Release — F-Droid repo + Gitea release / release (push) Successful in 6m18s
Reviewed-on: #34
2026-06-25 05:18:55 +00:00
3d8e3ca69e chore(release): prepare v2.8.0
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m5s
Bump versionName to 2.8.0 (versionCode 20800) — the merge of this to main
is what cuts the release. Move the accumulated 2.8.0 work out of
[Unreleased] into a dated CHANGELOG section and regenerate the F-Droid
per-version changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:42:56 +02:00
3b5c0be765 Merge pull request 'feat: full-text event search' (!33) from feat/event-search into release/v2.8.0
Reviewed-on: #33
2026-06-23 09:27:51 +00:00
013efef29e fix(search): clear on reopen and centre messages above the keyboard
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m14s
The search ViewModel is activity-scoped and outlives the overlay, so a reopened
search showed the previous query/results — reset the query when the screen
re-enters (peeking a result keeps it, as the screen stays composed under the
detail). Add imePadding so the idle/empty message re-centres in the area above
the keyboard instead of staying centred on the full page behind it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:05:26 +02:00
5457a34282 feat(search): show a recurring result's next occurrence
A recurring master's DTSTART is the series start, which for long-running series
reads as an old date and sorts into the past. For recurring hits (non-empty
RRULE/RDATE), resolve the occurrence nearest to now via the Instances provider —
the soonest upcoming within ~2 years, else the most recent past — and display
and sort by that. Falls back to the series start when no occurrence lies in the
window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:51:28 +02:00
82e93fda4c feat(search): full-text event search from the top bar
A magnifier in each calendar screen's top bar opens a search overlay: type a
query and matching events (title, location or description) appear, nearest-to-
today first; tapping a result opens its detail.

- Data: query the Events table directly with a LIKE selection on title /
  description / location (wildcards escaped), so search is unbounded in time and
  filtered provider-side. New SearchProjection + toSearchResult mapper reuse the
  .ics export's DURATION handling for recurring masters. Hidden calendars are
  filtered out, mirroring instances().
- SearchViewModel debounces the query (250 ms), needs >= 2 chars, and orders
  results upcoming-ascending then past-descending.
- SearchScreen: autofocused inline field in the top bar, GroupedRow results
  reusing the agenda row style, idle/empty states. Hosted as a CalendarHost
  overlay below detail/edit so a tapped result's detail draws on top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:42:37 +02:00
871cc34cab Merge pull request 'feat: 'New event' Quick Settings tile' (!32) from feat/qs-tile-new-event into release/v2.8.0
Reviewed-on: #32
2026-06-23 08:18:09 +00:00
5b99da32b0 fix(qs): silence StartActivityAndCollapseDeprecated lint error
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m15s
lintDebug aborts on the deprecated startActivityAndCollapse(Intent) overload in
the pre-34 branch, even though that overload is the only one available below
UpsideDownCake and is reached only there. Suppress the lint issue (and the
compiler deprecation) at the function level since the call is intentional and
version-gated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:13:33 +02:00
b8a7191fbe fix(nav): bring external new-event/open-date to the front
Some checks failed
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Failing after 3m36s
The create form and Settings/calendar-manager are sibling overlays in one Box,
with Settings drawn above the form. An external 'new event' request (QS tile,
launcher shortcut, widget) set the create state correctly but rendered the form
underneath an open Settings, forcing the user to back out first.

Dismiss the covering overlays (Settings, calendar manager, detail/edit, import)
when handling a Create or OpenDate nav request, so the requested destination is
revealed on top. Fixes the same latent bug for the shortcut and widget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:59:44 +02:00
6b0eb48056 Merge pull request 'feat: current-time line in day and week views' (!31) from feat/now-line into release/v2.8.0
Reviewed-on: #31
2026-06-23 07:59:12 +00:00
572a4734ea feat(qs): add a 'New event' Quick Settings tile
A stateless TileService that opens the create-event form on today — the same
action as the launcher 'New event' shortcut and the agenda widget's +. Reuses
MainActivity.openCreateIntent; wrapped in unlockAndRun and using the API 34+
startActivityAndCollapse(PendingIntent) form (deprecated Intent form below 34).
No new permission (BIND_QUICK_SETTINGS_TILE is system-side).

Discoverability: Settings → New event form gains an 'Add Quick Settings tile'
row that fires StatusBarManager.requestAddTileService (API 33+); on older
versions the tile is still addable manually from the system QS editor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:52:24 +02:00
7f8a9069c0 feat(views): show a current-time line in day and week views
All checks were successful
CI / ci (pull_request) Successful in 4m21s
A thin primary-coloured indicator (leading dot + line) marks the current
time across today's column in the day and week timelines, positioned on the
same HOUR_HEIGHT scale as the event blocks so it lines up with the grid.

Shared ui/common/NowLine.kt ticks once a minute, re-aligning to each minute
boundary to avoid drift, and only mounts on today's column so a single
coroutine runs. Renders nothing when today isn't in view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:36:40 +02:00
42 changed files with 1684 additions and 118 deletions

View File

@@ -5,15 +5,81 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.9.0] — 2026-06-25
### Added
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
time you start the app, instead of always opening on Week. Thanks to
@devinside for the suggestion ([#1]).
- The month widget is now interactive: tap any day to open it, or tap an event to
open its details. Previously only the month's prev/next/today controls
responded. Thanks to @devinside for spotting this ([#2]).
- Predictive back. Swiping back from a screen — an event, the editor, search or
settings — now follows your finger, shrinking the screen to preview what's
behind so you can see where Back will take you before you let go; slide it back
to cancel. The preview needs Android 14 or newer; on older versions Back works
as before.
- Respect for "Remove animations". If you've turned animations off in your
device's Accessibility settings, Calendula now honours it everywhere: motion
collapses to a quick fade and the back preview is skipped.
### Changed
- Smoother, more consistent motion throughout. Expanding sections, list updates
in search and agenda, and the onboarding screens now animate the same way
across the app, instead of some places sliding or growing while others popped
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
snapping, while paging within a view keeps its slide.
### Fixed
- Home-screen widgets now back out the way you came in. A day or event opened
from the Agenda widget keeps you in the agenda context, and from the Month
widget in the month context — pressing Back returns there instead of dropping
you on the week view. More broadly, the app now keeps a real view history:
switching views and drilling into a day are retraced by Back one step at a
time, down to your default view before the app exits. Thanks to @devinside for
reporting ([#2]).
## [2.8.0] — 2026-06-23
### Added
- Find events fast. A search button in the top bar of every calendar view opens
a search box — type a couple of letters and matching events (by title,
location or description) appear, soonest first with past events below. Tap a
result to open it. Search covers your whole calendar, not just what's on
screen, and skips calendars you've hidden. A recurring event shows its next
occurrence rather than the date the series first started.
- A current-time line in the day and week views. A thin coloured line marks the
present moment across today's column, so you can see at a glance where you are
in the day. It updates every minute and only appears when today is in view.
- Add and remove event guests. The create/edit form now has a Guests section:
add people by email or pick them from your contacts, mark each as required or
optional, and remove them. Calendula never sends invitations itself (it has no
internet access) — it only records the guests; if the event lives on a synced
account, that account may email them when it syncs, and on a local calendar no
one is notified. The form tells you which applies. Picking a guest from
contacts needs no contacts permission.
- Pick a location from your contacts. A contacts button beside the location
field drops a contact's address straight into an event — handy for a meeting
at someone's home or office. Like the guest picker, it needs no contacts
permission.
- A "New event" Quick Settings tile. Add it to your quick settings to jump
straight into the new-event form from anywhere. Settings → New event has a
one-tap button to add the tile (Android 13+); on older versions you can add it
from the quick-settings editor.
- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
reminder; snooze hides it and brings it back after a delay you pick in
Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
system won't re-post a reminder on its own, so Calendula schedules an exact
alarm to bring a snoozed one back on time.
### Changed
- Event details now show each guest's email beneath their name, instead of only
when no name is available.
- Crash and problem reports now open on the project's public Codeberg tracker,
where anyone can register and file an issue. Nothing is sent automatically —
you still review the report and submit it yourself in the browser.
## [2.7.5] — 2026-06-21
### Changed
@@ -541,3 +607,6 @@ automatically, with zero telemetry and no internet permission.
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
- F-Droid metadata stubs (DE + EN short/full descriptions)
- `.planning/` project-tracking documents
[#1]: https://codeberg.org/jlmakiola/calendula/issues/1
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2

View File

@@ -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 = 20705
versionName = "2.7.5"
versionCode = 20900
versionName = "2.9.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -39,6 +39,7 @@
<application
android:name=".CalendulaApp"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
@@ -88,6 +89,20 @@
android:excludeFromRecents="true"
android:launchMode="singleTask" />
<!-- Quick Settings tile: a one-tap "New event" shortcut in the QS panel.
Exported with BIND_QUICK_SETTINGS_TILE so only the system QS host can
bind it; the action mirrors the launcher "New event" shortcut. -->
<service
android:name=".qs.NewEventTileService"
android:exported="true"
android:icon="@drawable/ic_qs_new_event"
android:label="@string/qs_tile_new_event_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
no notification itself — a calendar app must (v1.4, Etar model).
Exported: the broadcast arrives from the provider's process. -->

View File

@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
@@ -140,18 +141,38 @@ class MainActivity : AppCompatActivity() {
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
}
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
// Launcher long-press "New event" shortcut. Static shortcut intents
// can't carry typed extras, so the action alone signals create-on-today.
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
getBooleanExtra(EXTRA_CREATE, false) ->
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
getStringExtra(EXTRA_DATE_ISO) != null ->
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
else -> null
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
val source = sourceViewOrNull()
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
return when {
// Launcher long-press "New event" shortcut. Static shortcut intents
// can't carry typed extras, so the action alone signals create-on-today.
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
getBooleanExtra(EXTRA_CREATE, false) ->
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
// A widget event tap carries both the occurrence and its source view;
// reminders (no source) fall through to [detailKeyOrNull] instead.
source != null && eventId != -1L -> WidgetNavRequest.OpenEvent(
eventId = eventId,
beginMillis = getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
endMillis = getLongExtra(EXTRA_END_MILLIS, 0L),
source = source,
)
source != null && getStringExtra(EXTRA_DATE_ISO) != null ->
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!, source)
else -> null
}
}
/** The view the launching widget represents, if any (see [EXTRA_SOURCE_VIEW]). */
private fun Intent.sourceViewOrNull(): CalendarView? =
getStringExtra(EXTRA_SOURCE_VIEW)
?.let { name -> CalendarView.entries.firstOrNull { it.name == name } }
private fun Intent.detailKeyOrNull(): LongArray? {
// A widget event tap (source present) is routed through [navRequestOrNull]
// so it can also set the base view; only sourceless reminder taps land here.
if (sourceViewOrNull() != null) return null
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
if (eventId == -1L) return null
return longArrayOf(
@@ -168,6 +189,10 @@ class MainActivity : AppCompatActivity() {
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
// The [CalendarView] (by name) of the widget a launch came from. Roots the
// in-app back stack in that view; absent for non-widget launches (reminders).
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
// Fired by the launcher long-press "New event" shortcut (res/xml/
// shortcuts.xml hardcodes this string — keep the two in sync).
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
@@ -190,14 +215,35 @@ class MainActivity : AppCompatActivity() {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/** Open the day view anchored on [date] (home-screen widgets). */
fun openDateIntent(context: Context, date: LocalDate): Intent =
/**
* Open the day view anchored on [date], drilled in over the launching
* widget's [source] view (home-screen widgets). Backing out of the day
* returns to [source], then to the default home view.
*/
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
Intent(context, MainActivity::class.java).apply {
data = "calendula://date/$date".toUri()
putExtra(EXTRA_DATE_ISO, date.toString())
putExtra(EXTRA_SOURCE_VIEW, source.name)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/**
* Open one occurrence's detail from a widget event tap, rooting the back
* stack in the widget's [source] view. Same occurrence-key shape as
* [eventDetailIntent]; the [source] extra is what distinguishes a widget
* tap (sets the base view) from a reminder tap (leaves it untouched).
*/
fun openEventIntent(
context: Context,
eventId: Long,
beginMillis: Long,
endMillis: Long,
source: CalendarView,
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
putExtra(EXTRA_SOURCE_VIEW, source.name)
}
/** Open the create-event form prefilled for [date] (home-screen widgets). */
fun openCreateIntent(context: Context, date: LocalDate): Intent =
Intent(context, MainActivity::class.java).apply {

View File

@@ -47,6 +47,14 @@ interface CalendarDataSource {
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
fun eventDetail(eventId: Long): 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.
*/
fun searchEvents(query: String): List<EventInstance>
/**
* The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
@@ -298,6 +306,89 @@ class AndroidCalendarDataSource @Inject constructor(
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
}
override fun searchEvents(query: String): List<EventInstance> {
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 selection = "($match) AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
return resolver.query(
CalendarContract.Events.CONTENT_URI,
SearchProjection.COLUMNS,
selection,
arrayOf(like, like, like),
CalendarContract.Events.DTSTART + " DESC",
)?.use { c ->
val reader = CursorColumnReader(c)
val out = ArrayList<EventInstance>(c.count)
while (c.moveToNext()) {
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) {
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
base.copy(
start = begin.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
)
} ?: base
} else {
base
}
}
out
} ?: emptyList()
}
/**
* The occurrence of [eventId] nearest to now: the soonest upcoming one
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
* within the same window behind. Null when neither exists (e.g. a series
* that only starts further out than the window) — the caller then keeps the
* series-start DTSTART. Returns (begin, end) epoch millis.
*/
private fun nearestOccurrenceMillis(eventId: Long): Pair<Long, Long>? {
val now = System.currentTimeMillis()
return occurrenceInWindow(eventId, now, now + OCCURRENCE_WINDOW_MILLIS, soonestFirst = true)
?: occurrenceInWindow(eventId, now - OCCURRENCE_WINDOW_MILLIS, now, soonestFirst = false)
}
private fun occurrenceInWindow(
eventId: Long,
beginMillis: Long,
endMillis: Long,
soonestFirst: Boolean,
): Pair<Long, Long>? {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
return resolver.query(
uri,
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
"${CalendarContract.Instances.EVENT_ID} = ?",
arrayOf(eventId.toString()),
order,
)?.use { c ->
if (!c.moveToFirst()) null else c.getLong(0) to c.getLong(1)
}
}
override fun eventDetail(eventId: Long): EventDetail? {
val attendees = queryAttendees(eventId)
val reminders = queryReminders(eventId)
@@ -955,5 +1046,12 @@ class AndroidCalendarDataSource @Inject constructor(
* together (by account) in the filter sheet and calendar manager.
*/
const val LOCAL_ACCOUNT_NAME = "Calendula"
/**
* How far ahead/behind a search looks for a recurring event's nearest
* occurrence (~2 years). Wide enough for everyday series; a series that
* next fires beyond it falls back to its series-start date.
*/
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
}
}

View File

@@ -16,6 +16,13 @@ interface CalendarRepository {
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
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]).
*/
suspend fun searchEvents(query: String): List<EventInstance>
/**
* The event-colour palette a calendar's account publishes; empty when it
* exposes none (see [CalendarDataSource.eventColorPalette]).

View File

@@ -80,6 +80,13 @@ class CalendarRepositoryImpl @Inject constructor(
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
}
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
if (query.isBlank()) return@withContext emptyList()
val hidden = prefs.hiddenCalendarIds.first()
dataSource.searchEvents(query)
.let { if (hidden.isEmpty()) it else it.filterNot { e -> e.calendarId in hidden } }
}
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
withContext(io) { dataSource.eventColorPalette(calendarId) }

View File

@@ -139,6 +139,44 @@ internal object EventExportProjection {
const val IDX_CALENDAR_ID = 13
}
/**
* Master/one-off Events rows matched by a full-text search. Like
* [EventExportProjection] it reads the Events table directly (so the search is
* unbounded in time), carrying DURATION for recurring rows that have no DTEND.
* Colour folds the calendar fallback like [InstanceProjection].
*/
internal object SearchProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.CALENDAR_ID,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_COLOR,
CalendarContract.Events.CALENDAR_COLOR,
CalendarContract.Events.EVENT_LOCATION,
// Recurrence markers: a non-empty RRULE or RDATE means the result should
// display its nearest occurrence, not the series-start DTSTART.
CalendarContract.Events.RRULE,
CalendarContract.Events.RDATE,
)
const val IDX_ID = 0
const val IDX_CALENDAR_ID = 1
const val IDX_TITLE = 2
const val IDX_DTSTART = 3
const val IDX_DTEND = 4
const val IDX_DURATION = 5
const val IDX_ALL_DAY = 6
const val IDX_EVENT_COLOR = 7
const val IDX_CALENDAR_COLOR = 8
const val IDX_LOCATION = 9
const val IDX_RRULE = 10
const val IDX_RDATE = 11
}
internal object AttendeeProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Attendees.ATTENDEE_NAME,

View File

@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
/**
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
* Instances query this reads the series master, so there is no instance id (the
* event id stands in as the list key) and recurring rows carry DURATION instead
* of DTEND — reconstruct the end the same way the `.ics` export does.
*/
internal fun ColumnReader.toSearchResult(): EventInstance? {
val dtStart = getLong(SearchProjection.IDX_DTSTART)
if (dtStart < 0L) return null
val end = when {
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
}.coerceAtLeast(dtStart)
val rawTitle = getString(SearchProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
getInt(SearchProjection.IDX_CALENDAR_COLOR)
} else {
getInt(SearchProjection.IDX_EVENT_COLOR)
}
val eventId = getLong(SearchProjection.IDX_ID)
return EventInstance(
instanceId = eventId,
eventId = eventId,
calendarId = getLong(SearchProjection.IDX_CALENDAR_ID),
title = title,
start = dtStart.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
color = color,
location = getString(SearchProjection.IDX_LOCATION),
)
}

View File

@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek
@@ -69,6 +70,20 @@ class SettingsPrefs @Inject constructor(
store.edit { it[WEEK_START_KEY] = pref.name }
}
/**
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
* the historical hard-coded startup view — so existing users see no change
* until they pick another. Also the bottom of the in-app view back stack, so
* back from any other view returns here before exiting.
*/
val defaultView: Flow<CalendarView> = store.data.map { prefs ->
prefs[DEFAULT_VIEW_KEY].toEnum(CalendarView.Week)
}
suspend fun setDefaultView(view: CalendarView) {
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
}
/**
* Optional event-form fields shown by default (the rest hide behind
* "more fields"). Stored comma-joined by enum name: an absent key means
@@ -244,6 +259,7 @@ class SettingsPrefs @Inject constructor(
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")

View File

@@ -0,0 +1,43 @@
package de.jeanlucmakiola.calendula.qs
import android.app.PendingIntent
import android.os.Build
import android.service.quicksettings.TileService
import de.jeanlucmakiola.calendula.MainActivity
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* Quick Settings tile: tapping it opens the create-event form on today — the
* same action as the launcher "New event" shortcut and the agenda widget's "+".
* A stateless action tile, so there is no on/off state to keep in sync.
*/
class NewEventTileService : TileService() {
// The pre-34 branch intentionally uses the deprecated Intent overload: it is
// the only form available below UpsideDownCake, and is reached only there.
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
override fun onClick() {
super.onClick()
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date
val intent = MainActivity.openCreateIntent(this, today)
// Launch only once the device is unlocked: creating an event behind the
// keyguard makes no sense, and the shade can't start an activity over a
// locked screen anyway.
unlockAndRun {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pending = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
startActivityAndCollapse(pending)
} else {
startActivityAndCollapse(intent)
}
}
}
}

View File

@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -12,20 +14,28 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.calendarFadeThrough
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
import kotlinx.datetime.LocalDate
@@ -34,14 +44,24 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* Holds the active top-level view (spec M1) and swaps between the calendar
* Holds the top-level view back stack (spec M1) and swaps between the calendar
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
* pill in their top bars writes back here via [onSelectView].
*
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
* A lateral switch (pill / drawer) builds a visit history so back retraces it
* (see [selectView]); a widget launch resets the stack to its own view; a date
* tap drills the day view on top. Pressing back pops one level, and the
* base-level [BackHandler] hands off to the system to exit once only the home
* view remains. So back from a widget-opened screen returns to that widget's
* view, then home; and back through pill switches walks the views in reverse.
*
* [requestedDetailKey] is an externally requested occurrence (a tapped
* reminder notification routed through MainActivity): it opens the detail
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
* so a later recomposition can't re-open it.
* so a later recomposition can't re-open it. (A widget event tap instead arrives
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
* widget's view.)
*/
@Composable
fun CalendarHost(
@@ -52,15 +72,24 @@ fun CalendarHost(
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(),
) {
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
val onSelectView: (CalendarView) -> Unit = { view = it }
// Wait for the persisted default view before seeding the stack, so the app
// opens straight on the user's choice instead of flashing a placeholder and
// correcting it. Brief blank first frame, matching the onboarding gate above.
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView))
}
val view = viewStack.last()
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
// Tapping a day in the month grid opens the day view anchored to that date.
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
val onOpenDay: (LocalDate) -> Unit = { date ->
pendingDayIso = date.toString()
view = CalendarView.Day
viewStack = viewStack.drillToDay()
}
// The event-detail screen (S4) is a full-screen destination hoisted here so
@@ -97,6 +126,12 @@ fun CalendarHost(
var showSettings by rememberSaveable { mutableStateOf(false) }
val onOpenSettings = { showSettings = true }
// Full-text search — its own overlay, opened from each calendar screen's
// top bar. Sits below the detail/edit overlays so tapping a result reveals
// the detail on top and backing out returns to the results.
var showSearch by rememberSaveable { mutableStateOf(false) }
val onOpenSearch = { showSearch = true }
// Calendar manager (reached from Settings) — its own overlay so it slides
// over Settings and survives view switches.
var showCalendars by rememberSaveable { mutableStateOf(false) }
@@ -137,16 +172,50 @@ fun CalendarHost(
}
}
// A home-screen widget launch asks to open a date (→ day view) or start a
// create. Handled once and cleared, mirroring [requestedDetailKey].
// Close every overlay that can sit over the calendar, so an externally
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
// top instead of underneath whatever the user had open.
fun dismissCoveringOverlays() {
showSettings = false
showCalendars = false
detailKey = null
editKey = null
importUri = null
importForm = null
}
// A home-screen widget launch asks to open a date (→ day view), open an
// event's detail, or start a create. Handled once and cleared, mirroring
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default.
LaunchedEffect(widgetNavRequest) {
when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> {
// Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = req.dateIso
view = CalendarView.Day
viewStack = viewBaseStack(defaultView, req.source).drillToDay()
onWidgetNavConsumed()
}
is WidgetNavRequest.OpenEvent -> {
// Root the stack in the widget's view, then open the occurrence
// detail over it (same key shape as a tapped event / reminder).
dismissCoveringOverlays()
createDateIso = null
viewStack = viewBaseStack(defaultView, req.source)
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
heldKey = key
detailKey = key
onWidgetNavConsumed()
}
is WidgetNavRequest.Create -> {
// External "new event" entries (QS tile / launcher shortcut /
// widget) must land on top of whatever is open — the form overlay
// sits below Settings/calendars in the Box, so without this it
// would open hidden underneath them.
dismissCoveringOverlays()
val iso = req.dateIso ?: Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
heldCreateIso = iso
@@ -161,36 +230,73 @@ fun CalendarHost(
val slideSpec = rememberCalendarSlideSpec()
// Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view,
// so back there falls through to the system and exits the app.
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
editKey != null || showSettings || showCalendars || importUri != null ||
importForm != null
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
viewStack = viewStack.dropLast(1)
}
Box(modifier = modifier.fillMaxSize()) {
when (view) {
CalendarView.Week -> WeekScreen(
selectedView = view,
onSelectView = onSelectView,
// Switching between the peer views (month/week/day/agenda) is lateral
// navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type.
val viewSwitch = calendarFadeThrough()
AnimatedContent(
targetState = view,
transitionSpec = { viewSwitch },
label = "view-switch",
) { currentView ->
when (currentView) {
CalendarView.Week -> WeekScreen(
selectedView = currentView,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
CalendarView.Day -> DayScreen(
selectedView = currentView,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
)
CalendarView.Month -> MonthScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
CalendarView.Agenda -> AgendaScreen(
selectedView = currentView,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
}
}
// Search overlay — below detail/edit in the Box so a tapped result's
// detail screen draws on top, and closing it returns to the results.
AnimatedVisibility(
visible = showSearch,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
SearchScreen(
onBack = { showSearch = false },
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
)
CalendarView.Day -> DayScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
)
CalendarView.Month -> MonthScreen(
selectedView = view,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
)
CalendarView.Agenda -> AgendaScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
)
}
@@ -293,3 +399,9 @@ fun CalendarHost(
}
}
}
/** Persists the view back stack across config change / process death by ordinal. */
private val viewStackSaver = listSaver<List<CalendarView>, Int>(
save = { stack -> stack.map(CalendarView::ordinal) },
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
)

View File

@@ -0,0 +1,29 @@
package de.jeanlucmakiola.calendula.ui
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.ui.common.CalendarView
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/**
* Supplies [CalendarHost] with the user's default startup view (M1). Held as a
* nullable [StateFlow] so the host can wait for DataStore's first emission before
* seeding its view back stack — rendering nothing for that first frame rather
* than flashing the old hard-coded Week view and then correcting it.
*/
@HiltViewModel
class CalendarHostViewModel @Inject constructor(
prefs: SettingsPrefs,
) : ViewModel() {
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
}

View File

@@ -2,6 +2,9 @@ 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.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
@@ -20,6 +23,7 @@ import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RootScreen(
modifier: Modifier = Modifier,
@@ -51,32 +55,40 @@ fun RootScreen(
onDispose { lifecycle.removeObserver(obs) }
}
if (hasPermission) {
// 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()
when (onboardingDone) {
true -> CalendarHost(
modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
// 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()
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,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
modifier = modifier,
)
null -> {}
}
}
} else {
PermissionScreen(
onGranted = { hasPermission = true },
modifier = modifier,
)
null -> {}
}
} else {
PermissionScreen(
onGranted = { hasPermission = true },
modifier = modifier,
)
}
}

View File

@@ -1,14 +1,28 @@
package de.jeanlucmakiola.calendula.ui
import de.jeanlucmakiola.calendula.ui.common.CalendarView
/**
* A navigation a home-screen widget asked the app to perform when launched.
* Parsed from the launch intent in MainActivity and consumed once by
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
* they are not modelled here).
* [CalendarHost]. Every request carries the [source] view of the widget it came
* from (the agenda widget → [CalendarView.Agenda], the month widget →
* [CalendarView.Month]) so the in-app back stack roots itself in that view:
* backing out of the opened date/event returns to the widget's own view, not the
* default home. (Reminder notifications are not widgets — they keep the separate
* detail-key channel and leave the base view untouched.)
*/
sealed interface WidgetNavRequest {
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
data class OpenDate(val dateIso: String) : WidgetNavRequest
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
data class OpenEvent(
val eventId: Long,
val beginMillis: Long,
val endMillis: Long,
val source: CalendarView,
) : WidgetNavRequest
/** Open the create-event form prefilled for [dateIso] (today when null). */
data class Create(val dateIso: String?) : WidgetNavRequest

View File

@@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -46,6 +47,7 @@ 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.ui.common.calendarAnimateItem
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
@@ -75,6 +77,7 @@ fun AgendaScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(),
@@ -119,6 +122,7 @@ fun AgendaScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -187,6 +191,7 @@ private fun AgendaList(
AgendaEventRow(
event = event,
position = positionOf(index, day.events.size),
modifier = calendarAnimateItem(),
onClick = { onEventClick(event) },
)
}
@@ -218,11 +223,13 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
private fun AgendaEventRow(
event: EventInstance,
position: Position,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
modifier = modifier,
title = title,
summary = agendaTimeSummary(event),
position = position,
@@ -274,6 +281,7 @@ private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -292,6 +300,12 @@ private fun AgendaTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,

View File

@@ -4,7 +4,6 @@ import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@@ -73,6 +72,7 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
@@ -295,10 +295,9 @@ private fun CalendarEditor(
var confirmDelete by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme()
BackHandler(onBack = onClose)
Scaffold(
modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
topBar = {

View File

@@ -1,14 +1,56 @@
package de.jeanlucmakiola.calendula.ui.common
import android.provider.Settings
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.coroutines.cancellation.CancellationException
/**
* Whether the user has asked the system to remove animations (Settings →
* Accessibility → "Remove animations", which sets the global animator duration
* scale to 0). Compose animations do *not* honour this platform flag on their
* own, so the shared motion helpers in this file check it and fall back to a
* quick cross-fade — opacity only, no spatial movement — to respect the
* vestibular intent of the setting while keeping state changes legible.
*
* Read once at composition; the scale changes rarely and only takes full effect
* after a process restart anyway.
*/
@Composable
fun rememberReduceMotion(): Boolean {
val resolver = LocalContext.current.contentResolver
return remember(resolver) {
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}
}
/**
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
@@ -24,17 +66,160 @@ fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.fastSpatialSpec()
/**
* Horizontal slide for navigating between adjacent months/weeks.
* The fast effects spec from the active motion scheme, for opacity (fade)
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
* for use in non-composable transition lambdas and as the reduced-motion fallback.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.fastEffectsSpec()
/**
* Horizontal slide for navigating between adjacent months/weeks/days.
*
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
* @param fadeSpec effects spec for the reduced-motion fade, typically
* [rememberCalendarFadeSpec].
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
*/
fun calendarSlideTransition(
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 slideInHorizontally(spec) { w -> dir * w }
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
}
/**
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
* opacity, so it doubles as its own reduced-motion form.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarFadeThrough(): ContentTransform {
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
return fadeIn(fade).togetherWith(fadeOut(fade))
}
/**
* Enter transition for a vertically-revealed section (expandable rows, inline
* fields): height grows from the top while fading in. Under reduced motion the
* height growth is dropped, leaving a quick fade.
*
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
* originally inlined in the event edit form, so every expandable surface in the
* app reveals the same way.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
}
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
}
/**
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
* motion keeps the fade only.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
}
}
/**
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
* app's motion scheme instead of Compose's default spring. Returns a bare
* [Modifier] under reduced motion so rows snap into place. Requires the list to
* supply stable item keys.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
if (reduceMotion) {
Modifier
} else {
Modifier.animateItem(
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
)
}
/**
* Standard Android predictive-back transform for a full-screen overlay: as the
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
* swiped edge and rounds its corners, previewing what's behind. Completing the
* gesture invokes [onBack]; cancelling springs it back.
*
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
* once and apply the returned [Modifier] to that screen's root so the preview
* respects the same back semantics. Under reduced motion the visual preview is
* skipped (the back still works); on API < 34 the system delivers no progress,
* so it degrades to a plain back.
*/
@Composable
fun Modifier.predictiveBack(
onBack: () -> Unit,
enabled: Boolean = true,
reduceMotion: Boolean = rememberReduceMotion(),
): Modifier {
val progress = remember { Animatable(0f) }
var fromLeftEdge by remember { mutableStateOf(true) }
PredictiveBackHandler(enabled = enabled) { events ->
try {
events.collect { event ->
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
}
onBack()
progress.snapTo(0f)
} catch (_: CancellationException) {
progress.animateTo(0f)
}
}
if (reduceMotion) return this
return this.graphicsLayer {
val p = progress.value
val scale = 1f - 0.1f * p
scaleX = scale
scaleY = scale
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
shape = RoundedCornerShape(32.dp.toPx() * p)
clip = p > 0f
}
}

View File

@@ -49,3 +49,31 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
return available[(i + 1) % available.size]
}
/**
* The top-level view back stack (bottom → top): the [default] home view always
* sits at the bottom. Pressing back pops one level until only the home view
* remains, then the system exits the app. Three operations move it:
*
* - [selectView] — a lateral switch (the pill or the drawer). It keeps a visit
* history so back retraces it: switching to a not-yet-visited view pushes it on
* top; switching to one already in the stack pops back to it (collapsing the
* loop, so the depth stays bounded by the number of distinct views). Selecting
* the home view collapses to just `[default]`.
* - [viewBaseStack] — the stack a widget launch resets to: just `[default]` when
* the widget's view is the home view, else `[default, source]`. A widget entry
* starts a fresh context rather than extending in-app history.
* - [drillToDay] — pushes the day view on top (a date tap from the month grid or
* a widget), unless the day view is already current.
*/
fun List<CalendarView>.selectView(target: CalendarView): List<CalendarView> {
val existing = indexOf(target)
return if (existing >= 0) take(existing + 1) else this + target
}
fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarView> =
if (source == default) listOf(default) else listOf(default, source)
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
fun List<CalendarView>.drillToDay(): List<CalendarView> =
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -73,11 +72,11 @@ fun CollapsingScaffold(
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
BackHandler(onBack = onBack)
val scrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = modifier
.predictiveBack(onBack = onBack)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.nestedScroll(scrollBehavior.nestedScrollConnection),

View File

@@ -0,0 +1,90 @@
package de.jeanlucmakiola.calendula.ui.common
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.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
private val DotSize = 8.dp
private val LineThickness = 2.dp
/**
* Current wall-clock instant that updates once a minute, re-aligning to each
* minute boundary (rather than ticking a fixed 60 s) so it never drifts toward
* the middle of a minute. Drives the "now" line in the day/week grids.
*/
@Composable
private fun rememberCurrentMinute(): State<Instant> {
val instant = remember { mutableStateOf(Clock.System.now()) }
LaunchedEffect(Unit) {
while (true) {
val now = Clock.System.now()
instant.value = now
delay(60_000L - now.toEpochMilliseconds() % 60_000L)
}
}
return instant
}
/**
* A thin "current time" indicator — a leading dot plus a line — drawn across a
* day column. Positioned on the same [hourHeight] scale the event blocks use so
* it lines up with the grid, and refreshed each minute. Renders nothing unless
* the wall clock is on [date]; callers mount it only for the column showing
* today, so the per-minute tick runs on a single column.
*/
@Composable
fun NowLine(
date: LocalDate,
hourHeight: Dp,
modifier: Modifier = Modifier,
) {
val now by rememberCurrentMinute()
val local = now.toLocalDateTime(TimeZone.currentSystemDefault())
if (local.date != date) return
val minutes = local.hour * 60 + local.minute
val top = hourHeight * (minutes / 60f)
val color = MaterialTheme.colorScheme.primary
Box(
modifier = modifier
.fillMaxWidth()
.height(DotSize)
.offset(y = top - DotSize / 2),
contentAlignment = Alignment.CenterStart,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(LineThickness)
.background(color),
)
// The dot anchors the line to the gutter edge, mirroring the standard
// calendar "now" marker; drawn after the line so it sits on top.
Box(
modifier = Modifier
.size(DotSize)
.background(color, CircleShape),
)
}
}

View File

@@ -173,7 +173,11 @@ fun ReminderDefaultPicker(
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(visible = customExpanded) {
AnimatedVisibility(
visible = customExpanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
CustomReminderEditor(
amountText = amountText,
onAmountChange = { amountText = it },

View File

@@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
@@ -71,8 +72,11 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
@@ -105,6 +109,7 @@ fun DayScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
initialDateIso: String? = null,
@@ -188,6 +193,7 @@ fun DayScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -233,6 +239,8 @@ private fun DayContent(
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-day AnimatedContent so the vertical scroll position
// survives day-to-day swipes. We only centre on noon once, on first entry
@@ -283,7 +291,7 @@ private fun DayContent(
DayUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "day-transition",
) { s ->
when (s) {
@@ -340,6 +348,7 @@ private fun DayTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -358,6 +367,12 @@ private fun DayTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
@@ -496,6 +511,7 @@ private fun Timeline(
blocks = state.timed,
dark = dark,
date = state.date,
today = state.today,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
modifier = Modifier
@@ -512,6 +528,7 @@ private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
date: LocalDate,
today: LocalDate,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
@@ -556,6 +573,10 @@ private fun DayColumnCard(
.padding(horizontal = 1.dp),
)
}
// Current-time line, on top of the events, only on today's column.
if (date == today) {
NowLine(date = date, hourHeight = HOUR_HEIGHT)
}
}
}
}

View File

@@ -6,7 +6,6 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@@ -92,6 +91,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale
@@ -132,8 +132,6 @@ fun EventDetailScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
BackHandler(onBack = onBack)
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
@@ -214,6 +212,7 @@ fun EventDetailScreen(
}
Scaffold(
modifier = Modifier.predictiveBack(onBack = onBack),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(

View File

@@ -8,14 +8,9 @@ import android.content.pm.PackageManager
import android.net.Uri
import android.provider.ContactsContract
import android.util.Patterns
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
@@ -63,7 +58,6 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -121,6 +115,9 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.toRRule
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
@@ -202,8 +199,6 @@ fun EventEditScreen(
viewModel.reset()
onClose()
}
BackHandler(onBack = close)
// The event vanished between the detail screen and the edit tap — fall
// back to the detail screen, which shows its own failure state.
LaunchedEffect(loadFailed) {
@@ -253,6 +248,7 @@ fun EventEditScreen(
}
Scaffold(
modifier = Modifier.predictiveBack(onBack = close),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
@@ -410,7 +406,6 @@ private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
* spring open with the expressive spatial spec instead of popping in.
* (Initially-visible sections render without animating.)
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun OptionalFormSection(
visible: Boolean,
@@ -418,10 +413,8 @@ private fun OptionalFormSection(
) {
AnimatedVisibility(
visible = visible,
enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()),
exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()),
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
Column(modifier = Modifier.fillMaxWidth(), content = content)
}

View File

@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.imports
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -39,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.OptionCard
/**
@@ -57,7 +57,6 @@ fun ImportScreen(
) {
LaunchedEffect(uri) { viewModel.load(uri) }
val state by viewModel.state.collectAsStateWithLifecycle()
BackHandler(onBack = onClose)
// A single event isn't shown here — it opens the create form for review.
LaunchedEffect(state) {
@@ -65,7 +64,9 @@ fun ImportScreen(
}
Scaffold(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.import_title)) },

View File

@@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -65,6 +66,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
@@ -86,6 +89,7 @@ fun MonthScreen(
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(),
@@ -161,6 +165,7 @@ fun MonthScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -214,6 +219,8 @@ private fun MonthContent(
val threshold = with(density) { 6.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
@@ -240,7 +247,7 @@ private fun MonthContent(
MonthUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "month-transition",
) { s ->
when (s) {
@@ -261,6 +268,7 @@ private fun MonthTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -279,6 +287,12 @@ private fun MonthTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,

View File

@@ -0,0 +1,259 @@
package de.jeanlucmakiola.calendula.ui.search
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.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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
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.SearchOff
import androidx.compose.material3.ExperimentalMaterial3Api
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.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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
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.ui.common.calendarAnimateItem
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
import java.time.Instant as JavaInstant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* 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].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
onBack: () -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
viewModel: SearchViewModel = hiltViewModel(),
) {
val query by viewModel.query.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
// 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.
LaunchedEffect(Unit) {
viewModel.setQuery("")
focusRequester.requestFocus()
keyboard?.show()
}
Scaffold(
modifier = modifier.predictiveBack(onBack = onBack),
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
TopAppBar(
title = {
InlineTextField(
value = query,
onValueChange = viewModel::setQuery,
placeholder = stringResource(R.string.search_hint),
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,
),
)
},
) { 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).
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding(),
) {
when (val s = state) {
SearchUiState.Idle -> SearchMessage(
icon = null,
text = stringResource(R.string.search_idle_hint),
)
is SearchUiState.Empty -> SearchMessage(
icon = Icons.Default.SearchOff,
text = stringResource(R.string.search_empty, s.query),
)
is SearchUiState.Results -> SearchResults(
events = s.events,
onEventClick = onEventClick,
)
}
}
}
}
@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 = calendarAnimateItem(),
onClick = { onEventClick(event) },
)
}
}
}
@Composable
private fun SearchResultRow(
event: EventInstance,
position: Position,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
GroupedRow(
modifier = modifier,
title = event.title,
summary = searchSummary(event),
position = position,
minHeight = 64.dp,
leading = {
Box(
modifier = Modifier
.size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp))
.background(pastelize(event.color, dark)),
)
},
onClick = onClick,
)
}
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
@Composable
private fun searchSummary(event: EventInstance): String {
val locale = currentLocale()
val zone = remember { ZoneId.systemDefault() }
val start = remember(event.start, zone) {
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
}
val dateText = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
}.format(start)
val timeText = if (event.isAllDay) {
stringResource(R.string.event_detail_all_day)
} else {
remember(locale) {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
}.format(start)
}
val base = "$dateText · $timeText"
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
}
@Composable
private fun SearchMessage(
icon: ImageVector?,
text: String,
) {
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(16.dp))
}
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}

View File

@@ -0,0 +1,77 @@
package de.jeanlucmakiola.calendula.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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 kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlin.time.Clock
import javax.inject.Inject
/** Shortest query that triggers a search; one character matches almost everything. */
private const val MIN_QUERY_LENGTH = 2
sealed interface SearchUiState {
/** No query yet, or one too short to search — show the prompt. */
data object Idle : 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
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@HiltViewModel
class SearchViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
val state: StateFlow<SearchUiState> = _query
.debounce(250L)
.map { it.trim() }
.distinctUntilChanged()
.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))
}
}
.catch { emit(SearchUiState.Idle) }
.flowOn(io)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
fun setQuery(value: String) {
_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 }
}
}

View File

@@ -1,12 +1,16 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest
import android.app.StatusBarManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.annotation.RequiresApi
import android.text.format.DateFormat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -80,11 +84,16 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
import de.jeanlucmakiola.calendula.ui.common.Position
@@ -399,6 +408,7 @@ private fun AppearanceScreen(
) {
var showTheme by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) }
var showDefaultView by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance),
@@ -410,6 +420,12 @@ private fun AppearanceScreen(
position = Position.Top,
onClick = { showTheme = true },
)
GroupedRow(
title = stringResource(R.string.settings_default_view),
summary = stringResource(state.defaultView.labelRes),
position = Position.Middle,
onClick = { showDefaultView = true },
)
GroupedRow(
title = stringResource(R.string.settings_dynamic_color),
summary = if (state.dynamicColorAvailable) {
@@ -459,6 +475,16 @@ private fun AppearanceScreen(
onDismiss = { showWeekStart = false },
)
}
if (showDefaultView) {
OptionPicker(
title = stringResource(R.string.settings_default_view),
options = IMPLEMENTED_VIEWS,
selected = state.defaultView,
label = { stringResource(it.labelRes) },
onSelect = viewModel::setDefaultView,
onDismiss = { showDefaultView = false },
)
}
}
@Composable
@@ -514,9 +540,39 @@ private fun EventFormScreen(
)
},
)
// One-tap add of the "New event" Quick Settings tile. The system prompt
// is API 33+; on older versions the tile is still addable manually from
// the QS editor, so the row simply doesn't appear there.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Spacer(Modifier.height(24.dp))
val context = LocalContext.current
GroupedRow(
title = stringResource(R.string.settings_qs_tile),
summary = stringResource(R.string.settings_qs_tile_hint),
position = Position.Alone,
onClick = { requestAddQsTile(context) },
)
}
}
}
/**
* Ask the system to add the "New event" Quick Settings tile (API 33+). The OS
* shows its own confirmation dialog and handles the already-added case, so no
* result handling is needed here.
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun requestAddQsTile(context: Context) {
val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return
statusBar.requestAddTileService(
ComponentName(context, NewEventTileService::class.java),
context.getString(R.string.qs_tile_new_event_label),
Icon.createWithResource(context, R.drawable.ic_qs_new_event),
context.mainExecutor,
) { /* result code unused — the system surfaces its own feedback */ }
}
/**
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
@@ -620,7 +676,11 @@ private fun NotificationsScreen(
}
},
)
AnimatedVisibility(visible = expanded) {
AnimatedVisibility(
visible = expanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
Column {
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
GroupedRow(

View File

@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CalendarView
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
@@ -17,6 +18,8 @@ data class SettingsUiState(
val dynamicColor: Boolean = true,
val dynamicColorAvailable: Boolean = true,
val weekStart: WeekStartPref = WeekStartPref.AUTO,
/** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week,
/** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
/** Whether Calendula posts reminder notifications (v1.4). */

View File

@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -72,8 +73,10 @@ class SettingsViewModel @Inject constructor(
) { overrides, allDayOverrides, calendars ->
ReminderOverrides(overrides, allDayOverrides, calendars)
},
) { base, defaults, overrides ->
prefs.defaultView,
) { base, defaults, overrides, defaultView ->
base.copy(
defaultView = defaultView,
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -115,6 +118,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setWeekStart(pref) }
}
fun setDefaultView(view: CalendarView) {
viewModelScope.launch { prefs.setDefaultView(view) }
}
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}

View File

@@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
@@ -76,8 +77,11 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
@@ -113,6 +117,7 @@ fun WeekScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
@@ -193,6 +198,7 @@ fun WeekScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -243,6 +249,8 @@ private fun WeekContent(
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-week AnimatedContent so the vertical scroll position
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
@@ -296,7 +304,7 @@ private fun WeekContent(
WeekUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "week-transition",
) { s ->
when (s) {
@@ -351,6 +359,7 @@ private fun WeekTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -369,6 +378,12 @@ private fun WeekTopBar(
}
},
actions = {
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
@@ -610,6 +625,7 @@ private fun Timeline(
blocks = state.timedByDay[day].orEmpty(),
dark = dark,
date = day,
today = state.today,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
modifier = Modifier
@@ -628,6 +644,7 @@ private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
date: LocalDate,
today: LocalDate,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
@@ -671,6 +688,10 @@ private fun DayColumnCard(
.padding(horizontal = 1.dp),
)
}
// Current-time line, on top of the events, only on today's column.
if (date == today) {
NowLine(date = date, hourHeight = HOUR_HEIGHT)
}
}
}
}

View File

@@ -41,6 +41,7 @@ import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
@@ -185,7 +186,11 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
modifier = GlanceModifier
.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date))),
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
)
}
@@ -199,11 +204,12 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
.padding(horizontal = 4.dp, vertical = 4.dp)
.clickable(
actionStartActivity(
MainActivity.eventDetailIntent(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Agenda,
),
),
),

View File

@@ -49,6 +49,7 @@ import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
@@ -209,7 +210,9 @@ private fun MonthHeader(label: String) {
resId = R.drawable.ic_widget_today,
contentDescription = context.getString(R.string.widget_today),
onClick = GlanceModifier.clickable(
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
actionStartActivity(
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
),
),
)
HeaderIcon(
@@ -300,8 +303,16 @@ private fun WeekRow(
@Composable
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
val context = LocalContext.current
Box(
modifier = GlanceModifier.width(colW).height(DAY_NUMBER_HEIGHT),
modifier = GlanceModifier
.width(colW)
.height(DAY_NUMBER_HEIGHT)
// Tap a day number to open that day, rooted in the month view so back
// returns to the month grid.
.clickable(
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
),
contentAlignment = Alignment.Center,
) {
Box(
@@ -353,7 +364,24 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
@Composable
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
val context = LocalContext.current
Box(modifier = GlanceModifier.width(width).height(LANE_HEIGHT).padding(horizontal = 1.dp)) {
Box(
modifier = GlanceModifier
.width(width)
.height(LANE_HEIGHT)
.padding(horizontal = 1.dp)
// Tap an event bar to open its detail, rooted in the month view.
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Month,
),
),
),
) {
Box(
modifier = GlanceModifier
.fillMaxSize()

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Quick Settings tile icon ("New event"): a flat calendar+plus glyph. QS tile
icons are tinted by the system from their alpha, so this is a bare white
glyph on a transparent background (no brand circle, unlike the launcher
shortcut icon). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M11.5,10.5h1v2h2v1h-2v2h-1v-2h-2v-1h2z" />
</vector>

View File

@@ -233,6 +233,14 @@
<string name="agenda_empty_title">Nichts geplant</string>
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
<!-- Terminsuche -->
<string name="search_action">Suchen</string>
<string name="search_hint">Termine suchen</string>
<string name="search_back">Zurück</string>
<string name="search_clear">Löschen</string>
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
<!-- Startbildschirm-Widgets -->
<string name="widget_agenda_title">Anstehend</string>
<string name="widget_agenda_label">Calendula Agenda</string>
@@ -248,6 +256,11 @@
<string name="shortcut_new_event_short">Neuer Termin</string>
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
<!-- Schnelleinstellungen-Kachel -->
<string name="qs_tile_new_event_label">Neuer Termin</string>
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
<!-- Kalender-Filter (M3) -->
<string name="filter_title">Kalender</string>

View File

@@ -234,6 +234,14 @@
<string name="agenda_empty_title">Nothing scheduled</string>
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
<!-- Event search -->
<string name="search_action">Search</string>
<string name="search_hint">Search events</string>
<string name="search_back">Back</string>
<string name="search_clear">Clear</string>
<string name="search_idle_hint">Search your events by title, location or notes.</string>
<string name="search_empty">No events match “%1$s”.</string>
<!-- Home-screen widgets -->
<string name="widget_agenda_title">Upcoming</string>
<string name="widget_agenda_label">Calendula agenda</string>
@@ -256,6 +264,7 @@
<string name="settings_theme_system">System</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_default_view">Default view</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
<string name="settings_week_start">Week starts on</string>
@@ -291,7 +300,7 @@
<string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
<string name="settings_event_form_subtitle">Default fields for new events</string>
<string name="settings_notifications_subtitle">Event reminders</string>
<string name="settings_section_about">About</string>
@@ -362,6 +371,11 @@
<string name="shortcut_new_event_short">New event</string>
<string name="shortcut_new_event_long">Create a new event</string>
<!-- Quick Settings tile -->
<string name="qs_tile_new_event_label">New event</string>
<string name="settings_qs_tile">Add Quick Settings tile</string>
<string name="settings_qs_tile_hint">Add a “New event” tile to the Quick Settings panel.</string>
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>

View File

@@ -16,6 +16,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var calendarsResult: List<CalendarSource> = emptyList()
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var searchResult: (String) -> List<EventInstance> = { _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList()
@@ -51,6 +52,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun calendars(): List<CalendarSource> = calendarsResult
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis)
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)

View File

@@ -0,0 +1,94 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The pure back-stack rules behind [CalendarHost]'s top-level navigation:
* the home view is always the bottom, a lateral move keeps the stack one deep,
* and a date tap drills the day view on top. Verifies the back sequences the
* widget-UX fix promises (e.g. agenda widget → day → back → agenda → back → home).
*/
class ViewBackStackTest {
@Test
fun `a widget whose view is the home view resets to just the home`() {
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Week))
.containsExactly(CalendarView.Week)
}
@Test
fun `a widget launch resets to its view over the home`() {
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Month))
.containsExactly(CalendarView.Week, CalendarView.Month)
.inOrder()
}
@Test
fun `pill switches build a visit history that back retraces`() {
// Agenda → Day → Week → Month, each a new view, stacks the full history.
val stack = listOf(CalendarView.Agenda)
.selectView(CalendarView.Day)
.selectView(CalendarView.Week)
.selectView(CalendarView.Month)
assertThat(stack).containsExactly(
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
).inOrder()
// Back walks the views in reverse, not straight to the home view.
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Week)
}
@Test
fun `selecting an already-visited view collapses the loop back to it`() {
val stack = listOf(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.selectView(CalendarView.Agenda)
assertThat(stack).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder()
}
@Test
fun `selecting the home view collapses to just the home`() {
val stack = listOf(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.selectView(CalendarView.Week)
assertThat(stack).containsExactly(CalendarView.Week)
}
@Test
fun `drilling pushes the day view on top`() {
assertThat(listOf(CalendarView.Week, CalendarView.Month).drillToDay())
.containsExactly(CalendarView.Week, CalendarView.Month, CalendarView.Day)
.inOrder()
}
@Test
fun `drilling is a no-op when the day view is already current`() {
val stack = listOf(CalendarView.Week, CalendarView.Day)
assertThat(stack.drillToDay()).isEqualTo(stack)
}
@Test
fun `agenda widget date tap backs out agenda then home`() {
// openDate(source = Agenda): root in Agenda over the Week home, then drill.
val stack = viewBaseStack(CalendarView.Week, CalendarView.Agenda).drillToDay()
assertThat(stack)
.containsExactly(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.inOrder()
// Pressing back pops one level at a time down to the home view.
assertThat(stack.dropLast(1)).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder()
assertThat(stack.dropLast(2)).containsExactly(CalendarView.Week)
}
@Test
fun `month widget date tap backs out month then home`() {
val stack = viewBaseStack(CalendarView.Week, CalendarView.Month).drillToDay()
assertThat(stack.last()).isEqualTo(CalendarView.Day)
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Month)
assertThat(stack.first()).isEqualTo(CalendarView.Week)
}
@Test
fun `a widget whose view is the home view does not duplicate the home`() {
// Default = Agenda, agenda widget event tap → just the home, no extra layer.
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
.containsExactly(CalendarView.Agenda)
}
}

View File

@@ -0,0 +1,38 @@
### Added
- Find events fast. A search button in the top bar of every calendar view opens
a search box — type a couple of letters and matching events (by title,
location or description) appear, soonest first with past events below. Tap a
result to open it. Search covers your whole calendar, not just what's on
screen, and skips calendars you've hidden. A recurring event shows its next
occurrence rather than the date the series first started.
- A current-time line in the day and week views. A thin coloured line marks the
present moment across today's column, so you can see at a glance where you are
in the day. It updates every minute and only appears when today is in view.
- Add and remove event guests. The create/edit form now has a Guests section:
add people by email or pick them from your contacts, mark each as required or
optional, and remove them. Calendula never sends invitations itself (it has no
internet access) — it only records the guests; if the event lives on a synced
account, that account may email them when it syncs, and on a local calendar no
one is notified. The form tells you which applies. Picking a guest from
contacts needs no contacts permission.
- Pick a location from your contacts. A contacts button beside the location
field drops a contact's address straight into an event — handy for a meeting
at someone's home or office. Like the guest picker, it needs no contacts
permission.
- A "New event" Quick Settings tile. Add it to your quick settings to jump
straight into the new-event form from anywhere. Settings → New event has a
one-tap button to add the tile (Android 13+); on older versions you can add it
from the quick-settings editor.
- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
reminder; snooze hides it and brings it back after a delay you pick in
Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
system won't re-post a reminder on its own, so Calendula schedules an exact
alarm to bring a snoozed one back on time.
### Changed
- Event details now show each guest's email beneath their name, instead of only
when no name is available.
- Crash and problem reports now open on the project's public Codeberg tracker,
where anyone can register and file an issue. Nothing is sent automatically —
you still review the report and submit it yourself in the browser.

View File

@@ -0,0 +1,33 @@
### Added
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
time you start the app, instead of always opening on Week. Thanks to
@devinside for the suggestion ([#1]).
- The month widget is now interactive: tap any day to open it, or tap an event to
open its details. Previously only the month's prev/next/today controls
responded. Thanks to @devinside for spotting this ([#2]).
- Predictive back. Swiping back from a screen — an event, the editor, search or
settings — now follows your finger, shrinking the screen to preview what's
behind so you can see where Back will take you before you let go; slide it back
to cancel. The preview needs Android 14 or newer; on older versions Back works
as before.
- Respect for "Remove animations". If you've turned animations off in your
device's Accessibility settings, Calendula now honours it everywhere: motion
collapses to a quick fade and the back preview is skipped.
### Changed
- Smoother, more consistent motion throughout. Expanding sections, list updates
in search and agenda, and the onboarding screens now animate the same way
across the app, instead of some places sliding or growing while others popped
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
snapping, while paging within a view keeps its slide.
### Fixed
- Home-screen widgets now back out the way you came in. A day or event opened
from the Agenda widget keeps you in the agenda context, and from the Month
widget in the month context — pressing Back returns there instead of dropping
you on the week view. More broadly, the app now keeps a real view history:
switching views and drilling into a day are retraced by Back one step at a
time, down to your default view before the app exits. Thanks to @devinside for
reporting ([#2]).