feat(nav): interactive month widget + history-retracing back
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>
This commit is contained in:
16
CHANGELOG.md
16
CHANGELOG.md
@@ -12,14 +12,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
|
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
|
time you start the app, instead of always opening on Week. Thanks to
|
||||||
@devinside for the suggestion ([#1]).
|
@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]).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Home-screen widgets now back out the way you came in. Opening a day or event
|
- 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 the Month widget
|
from the Agenda widget keeps you in the agenda context, and from the Month
|
||||||
keeps you in the month context — pressing Back returns to that view instead of
|
widget in the month context — pressing Back returns there instead of dropping
|
||||||
dropping you on the week view. The app now keeps a proper view history: Back
|
you on the week view. More broadly, the app now keeps a real view history:
|
||||||
steps from a drilled-in day to the view you opened it from, then to your
|
switching views and drilling into a day are retraced by Back one step at a
|
||||||
default view, then leaves the app. Thanks to @devinside for reporting ([#2]).
|
time, down to your default view before the app exits. Thanks to @devinside for
|
||||||
|
reporting ([#2]).
|
||||||
|
|
||||||
## [2.8.0] — 2026-06-23
|
## [2.8.0] — 2026-06-23
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
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.common.viewBaseStack
|
||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
@@ -46,11 +47,12 @@ import kotlin.time.Clock
|
|||||||
* pill in their top bars writes back here via [onSelectView].
|
* pill in their top bars writes back here via [onSelectView].
|
||||||
*
|
*
|
||||||
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
|
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
|
||||||
* A lateral move (pill / drawer / a widget establishing its own view) replaces
|
* A lateral switch (pill / drawer) builds a visit history so back retraces it
|
||||||
* the non-home top; a date tap drills the day view on top. Pressing back pops
|
* (see [selectView]); a widget launch resets the stack to its own view; a date
|
||||||
* one level, and the base-level [BackHandler] hands off to the system to exit
|
* tap drills the day view on top. Pressing back pops one level, and the
|
||||||
* once only the home view remains. So back from a widget-opened screen returns
|
* base-level [BackHandler] hands off to the system to exit once only the home
|
||||||
* to that widget's view, then home, then out.
|
* 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
|
* [requestedDetailKey] is an externally requested occurrence (a tapped
|
||||||
* reminder notification routed through MainActivity): it opens the detail
|
* reminder notification routed through MainActivity): it opens the detail
|
||||||
@@ -79,7 +81,7 @@ fun CalendarHost(
|
|||||||
mutableStateOf(listOf(defaultView))
|
mutableStateOf(listOf(defaultView))
|
||||||
}
|
}
|
||||||
val view = viewStack.last()
|
val view = viewStack.last()
|
||||||
val onSelectView: (CalendarView) -> Unit = { viewStack = viewBaseStack(defaultView, it) }
|
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
|
||||||
|
|
||||||
// Tapping a day in the month grid opens the day view anchored to that date.
|
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|||||||
@@ -52,19 +52,27 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The top-level view back stack (bottom → top): the [default] home view always
|
* The top-level view back stack (bottom → top): the [default] home view always
|
||||||
* sits at the bottom, with a laterally-selected or widget-entered view layered
|
* sits at the bottom. Pressing back pops one level until only the home view
|
||||||
* above it. Pressing back pops one level until only the home view remains, then
|
* remains, then the system exits the app. Three operations move it:
|
||||||
* the system exits the app.
|
|
||||||
*
|
*
|
||||||
* [viewBaseStack] is the stack after a lateral move to [top] (the switcher pill,
|
* - [selectView] — a lateral switch (the pill or the drawer). It keeps a visit
|
||||||
* the drawer, or a widget establishing its own view): just `[default]` when
|
* history so back retraces it: switching to a not-yet-visited view pushes it on
|
||||||
* [top] is the home view, else `[default, top]`. It collapses any prior drill-in
|
* top; switching to one already in the stack pops back to it (collapsing the
|
||||||
* so the depth never grows past a single lateral level. [drillToDay] pushes the
|
* loop, so the depth stays bounded by the number of distinct views). Selecting
|
||||||
* day view on top — a date tap from the month grid or a widget — unless the day
|
* the home view collapses to just `[default]`.
|
||||||
* view is already current.
|
* - [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 viewBaseStack(default: CalendarView, top: CalendarView): List<CalendarView> =
|
fun List<CalendarView>.selectView(target: CalendarView): List<CalendarView> {
|
||||||
if (top == default) listOf(default) else listOf(default, top)
|
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). */
|
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
|
||||||
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
||||||
|
|||||||
@@ -303,8 +303,16 @@ private fun WeekRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||||
|
val context = LocalContext.current
|
||||||
Box(
|
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,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -356,7 +364,24 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
||||||
val context = LocalContext.current
|
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(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
@@ -12,25 +12,44 @@ import org.junit.jupiter.api.Test
|
|||||||
class ViewBackStackTest {
|
class ViewBackStackTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `lateral move to the home view collapses to just the home`() {
|
fun `a widget whose view is the home view resets to just the home`() {
|
||||||
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Week))
|
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Week))
|
||||||
.containsExactly(CalendarView.Week)
|
.containsExactly(CalendarView.Week)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `lateral move to a non-home view layers it over the home`() {
|
fun `a widget launch resets to its view over the home`() {
|
||||||
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Month))
|
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Month))
|
||||||
.containsExactly(CalendarView.Week, CalendarView.Month)
|
.containsExactly(CalendarView.Week, CalendarView.Month)
|
||||||
.inOrder()
|
.inOrder()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a lateral move never grows past one level above home`() {
|
fun `pill switches build a visit history that back retraces`() {
|
||||||
// Switching pill targets in turn replaces the top rather than stacking.
|
// Agenda → Day → Week → Month, each a new view, stacks the full history.
|
||||||
val afterMonth = viewBaseStack(CalendarView.Week, CalendarView.Month)
|
val stack = listOf(CalendarView.Agenda)
|
||||||
val afterAgenda = viewBaseStack(CalendarView.Week, CalendarView.Agenda)
|
.selectView(CalendarView.Day)
|
||||||
assertThat(afterMonth).hasSize(2)
|
.selectView(CalendarView.Week)
|
||||||
assertThat(afterAgenda).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder()
|
.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
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user