feat(widget): open the app from the widget headers (#18, #20)

Tapping a widget header now opens the app on a sensible top-level view,
via a shared WidgetNavRequest.OpenView(view?) — a concrete view roots
there over the default home, a null view resolves to the default home:

- Month widget: the month/year title opens the month view (#18). Its
  "today" button now snaps the grid back to the current month in place
  (the reset that used to sit on the title), so paging + jump-to-today
  are both reachable and the title is free to open the app.
- Agenda widget: the "Upcoming" title opens the default view (#20), so
  users whose home view is Week/Month can reach it in one tap instead of
  drilling through a day or event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 12:02:14 +02:00
parent 14b19cd902
commit 40d70d9f2b
6 changed files with 73 additions and 7 deletions

View File

@@ -14,8 +14,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
a reminder a week before *and* one on the day. The reminder pickers are now a reminder a week before *and* one on the day. The reminder pickers are now
multi-select; per-calendar overrides can still inherit the global default or multi-select; per-calendar overrides can still inherit the global default or
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]). turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
- Widget headers now open the app. Tapping the month/year title on the month
widget opens the app on the month view, and tapping the "Upcoming" title on
the agenda widget opens it on your default view — so there's a one-tap way
back into the app that lands where you'd expect, instead of only through a day
or event. The month widget's "today" button now snaps the grid back to the
current month in place. Thanks to @rgz46vic and @ptab for the suggestions
([#18], [#20]).
### Fixed ### Fixed
- Month widget arrows and "today" button work again. On release builds the
prev/next-month arrows and the jump-to-today control on the month widget did
nothing when tapped — code shrinking had stripped the tap handlers behind
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
- Disabled calendars no longer notify. Reminders for events in a calendar you - Disabled calendars no longer notify. Reminders for events in a calendar you
have disabled (Settings → Calendars) are now suppressed instead of still have disabled (Settings → Calendars) are now suppressed instead of still
popping up — matching how a disabled calendar's events already stay hidden popping up — matching how a disabled calendar's events already stay hidden
@@ -754,3 +765,5 @@ automatically, with zero telemetry and no internet permission.
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14 [#14]: https://codeberg.org/jlmakiola/calendula/issues/14
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16 [#16]: https://codeberg.org/jlmakiola/calendula/issues/16
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17 [#17]: https://codeberg.org/jlmakiola/calendula/issues/17
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20

View File

@@ -193,6 +193,12 @@ class MainActivity : AppCompatActivity() {
// An external date tap (launcher/clock) has no widget source, so it opens // An external date tap (launcher/clock) has no widget source, so it opens
// the day view rooted over the default home view (OpenDate source = null). // the day view rooted over the default home view (OpenDate source = null).
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) } calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
// A widget header tap: open a top-level view with no date drill-in. The
// empty string carried by [openViewIntent] means "the default home view".
if (hasExtra(EXTRA_OPEN_VIEW)) {
val name = getStringExtra(EXTRA_OPEN_VIEW).orEmpty()
return WidgetNavRequest.OpenView(CalendarView.entries.firstOrNull { it.name == name })
}
val source = sourceViewOrNull() val source = sourceViewOrNull()
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L) val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
return when { return when {
@@ -244,6 +250,10 @@ class MainActivity : AppCompatActivity() {
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO" private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE" private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
// A widget header tap asking to open a top-level view (no date drill-in).
// Its value is the target [CalendarView] name, or "" for the default view.
private const val EXTRA_OPEN_VIEW = "de.jeanlucmakiola.calendula.extra.OPEN_VIEW"
// The [CalendarView] (by name) of the widget a launch came from. Roots the // 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). // 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" private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
@@ -307,5 +317,19 @@ class MainActivity : AppCompatActivity() {
putExtra(EXTRA_DATE_ISO, date.toString()) putExtra(EXTRA_DATE_ISO, date.toString())
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
/**
* Open the app on a top-level [view] with no date drill-in — a widget
* header tap. A null [view] opens the user's default home view (the agenda
* widget's "Upcoming" title); a concrete view roots there over the default
* home (the month widget's month/year title → [CalendarView.Month]). The
* per-view data URI keeps distinct headers' PendingIntents from collapsing.
*/
fun openViewIntent(context: Context, view: CalendarView?): Intent =
Intent(context, MainActivity::class.java).apply {
data = "calendula://view/${view?.name ?: "default"}".toUri()
putExtra(EXTRA_OPEN_VIEW, view?.name ?: "")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
} }
} }

View File

@@ -212,6 +212,17 @@ fun CalendarHost(
detailKey = key detailKey = key
onWidgetNavConsumed() onWidgetNavConsumed()
} }
is WidgetNavRequest.OpenView -> {
// A widget header tap: land on a top-level view with no date
// drill-in. Reveal it by dropping any covering overlay, then root
// the stack on the target (null → the default home) over the
// default home — so backing out returns to the default, then exits.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = null
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
onWidgetNavConsumed()
}
is WidgetNavRequest.Create -> { is WidgetNavRequest.Create -> {
// External "new event" entries (QS tile / launcher shortcut / // External "new event" entries (QS tile / launcher shortcut /
// widget) must land on top of whatever is open — the form overlay // widget) must land on top of whatever is open — the form overlay

View File

@@ -31,4 +31,13 @@ sealed interface WidgetNavRequest {
/** Open the create-event form prefilled for [dateIso] (today when null). */ /** Open the create-event form prefilled for [dateIso] (today when null). */
data class Create(val dateIso: String?) : WidgetNavRequest data class Create(val dateIso: String?) : WidgetNavRequest
/**
* Open the app rooted on a top-level [view] with no date drill-in — a widget
* header tap. A null [view] means "the user's default home view" (the agenda
* widget's "Upcoming" title, issue #20); a concrete view roots there over the
* default home (the month widget's month/year title → [CalendarView.Month],
* issue #18), so backing out returns to the default view, then exits.
*/
data class OpenView(val view: CalendarView?) : WidgetNavRequest
} }

View File

@@ -193,6 +193,8 @@ private fun AgendaHeader() {
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp), modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Tap the "Upcoming" title to open the app on the user's default view
// (issue #20) — a null target resolves to the default home in the host.
Text( Text(
text = context.getString(R.string.widget_agenda_title), text = context.getString(R.string.widget_agenda_title),
style = TextStyle( style = TextStyle(
@@ -200,7 +202,11 @@ private fun AgendaHeader() {
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
), ),
modifier = GlanceModifier.defaultWeight(), modifier = GlanceModifier
.defaultWeight()
.clickable(
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
),
) )
IconButton( IconButton(
resId = R.drawable.ic_widget_refresh, resId = R.drawable.ic_widget_refresh,

View File

@@ -194,6 +194,8 @@ private fun MonthHeader(label: String) {
), ),
), ),
) )
// Tap the month/year title to open the app on the month view (issue #18),
// rooted over the default home so backing out returns there.
Text( Text(
text = label, text = label,
style = TextStyle( style = TextStyle(
@@ -204,16 +206,17 @@ private fun MonthHeader(label: String) {
), ),
modifier = GlanceModifier modifier = GlanceModifier
.defaultWeight() .defaultWeight()
.clickable(actionRunCallback<ResetMonthAction>()), .clickable(
actionStartActivity(
MainActivity.openViewIntent(context, CalendarView.Month),
),
),
) )
// The "today" button snaps the grid back to the current month in place.
HeaderIcon( HeaderIcon(
resId = R.drawable.ic_widget_today, resId = R.drawable.ic_widget_today,
contentDescription = context.getString(R.string.widget_today), contentDescription = context.getString(R.string.widget_today),
onClick = GlanceModifier.clickable( onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
actionStartActivity(
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
),
),
) )
HeaderIcon( HeaderIcon(
resId = R.drawable.ic_widget_chevron_right, resId = R.drawable.ic_widget_chevron_right,