diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt new file mode 100644 index 00000000..7954e3c4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt @@ -0,0 +1,328 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.ui.week.TimedBlock +import de.jeanlucmakiola.floret.identity.rememberReduceMotion +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** Thickness of the out-of-view edge bar. */ +private val EDGE_BAR_THICKNESS = 3.dp + +/** Band the bar sits in, and the room a revealed event keeps from the edge. */ +private val EDGE_TOUCH_HEIGHT = 24.dp + +/** How far from the viewport's edge a tap on empty column still hits the bar. */ +private val EDGE_TAP_REACH = 16.dp + +/** + * How far the bar sits in from the viewport's edges — enough to clear the + * timeline's rounded 16dp corners on the outer columns. + */ +private val EDGE_BAR_VERTICAL_INSET = 4.dp +private val EDGE_BAR_HORIZONTAL_INSET = 6.dp + +/** Distance outside the viewport over which a bar fades before its event appears. */ +private val EDGE_FADE_LEAD = 24.dp + +/** The nearest timed blocks wholly above and below a column's viewport. */ +@Immutable +data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) { + companion object { + val None = OutOfViewEvents(null, null) + } +} + +/** Which edge of the viewport a hidden event sits past. */ +enum class ViewportEdge { Top, Bottom } + +/** Where [block]'s rendered bottom lands, in minutes — short blocks keep a floor. */ +internal fun renderedEndMin(block: TimedBlock): Float = + maxOf(block.endMin.toFloat(), block.startMin + MIN_EVENT_FRACTION * 60f) + +/** + * The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin] + * (minutes of the day), nearest first. A block leaves this set as soon as any + * part of its rendered height enters the viewport. + */ +fun outOfViewEvents( + blocks: List, + topMin: Float, + bottomMin: Float, +): OutOfViewEvents { + val above = blocks + .filter { renderedEndMin(it) <= topMin } + .maxWithOrNull(compareBy({ renderedEndMin(it) }, { it.startMin })) + val below = blocks + .filter { it.startMin >= bottomMin } + .minWithOrNull(compareBy({ it.startMin }, { -renderedEndMin(it) })) + return OutOfViewEvents(above, below) +} + +/** + * The scroll offset that brings [block] into a [viewportPx]-tall viewport from + * past [edge], with [marginPx] of room so it clears the edge bar. An event above + * lands with its start at the top; one below lands whole at the bottom when it + * fits, and with its start at the top when it does not. + */ +fun revealScrollTarget( + block: TimedBlock, + edge: ViewportEdge, + hourPx: Float, + viewportPx: Int, + marginPx: Float, + maxScroll: Int, +): Int { + val startPx = block.startMin / 60f * hourPx + val endPx = renderedEndMin(block) / 60f * hourPx + val target = when (edge) { + ViewportEdge.Top -> startPx - marginPx + ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx) + } + return target.roundToInt().coerceIn(0, maxOf(maxScroll, 0)) +} + +/** + * The edge whose bar a tap at [y] (content px) lands on, if that bar is showing + * and the tap is within [reachPx] of the viewport's edge. + */ +fun tappedEdge( + events: OutOfViewEvents, + y: Float, + viewportTop: Float, + viewportPx: Int, + reachPx: Float, +): ViewportEdge? = when { + events.above != null && y - viewportTop <= reachPx -> ViewportEdge.Top + events.below != null && viewportTop + viewportPx - y <= reachPx -> ViewportEdge.Bottom + else -> null +} + +/** Fade while the event is still outside; never draw its bar at the boundary. */ +internal fun edgeBarAlpha(gapPx: Float, fadeLeadPx: Float): Float = when { + gapPx <= 0f -> 0f + fadeLeadPx <= 0f -> 1f + else -> (gapPx / fadeLeadPx).coerceIn(0f, 1f) +} + +/** + * The out-of-view state of one day column (#188): which events hide past its + * edges, and the scroll that reveals them. + * + * The bars take no touches themselves, so event blocks under them keep their + * taps and drags; the column's own tap handler offers taps to [tap] first. + */ +@Stable +class OutOfViewEdges internal constructor( + private val scrollState: ScrollState, + private val scope: CoroutineScope, +) { + internal var blocks by mutableStateOf(emptyList()) + internal var hourPx by mutableFloatStateOf(0f) + internal var fadeLeadPx by mutableFloatStateOf(0f) + internal var marginPx by mutableFloatStateOf(0f) + internal var reachPx by mutableFloatStateOf(0f) + internal var reduceMotion = false + internal var revealSpec: FiniteAnimationSpec? = null + + val events: OutOfViewEvents by derivedStateOf { + val viewport = scrollState.viewportSize + if (viewport <= 0 || hourPx <= 0f) { + OutOfViewEvents.None + } else { + val top = scrollState.value.toFloat() + outOfViewEvents( + blocks = blocks, + topMin = top / hourPx * 60f, + bottomMin = (top + viewport) / hourPx * 60f, + ) + } + } + + /** Scrolls the event hidden past [edge] into view, if there is one. */ + fun reveal(edge: ViewportEdge) { + val block = if (edge == ViewportEdge.Top) events.above else events.below + block ?: return + val target = revealScrollTarget( + block = block, + edge = edge, + hourPx = hourPx, + viewportPx = scrollState.viewportSize, + marginPx = marginPx, + maxScroll = scrollState.maxValue, + ) + val spec = revealSpec + scope.launch { + if (reduceMotion || spec == null) { + scrollState.scrollTo(target) + } else { + scrollState.animateScrollTo(target, spec) + } + } + } + + /** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */ + fun tap(y: Float): Boolean { + val edge = tappedEdge( + events = events, + y = y, + viewportTop = scrollState.value.toFloat(), + viewportPx = scrollState.viewportSize, + reachPx = reachPx, + ) ?: return false + reveal(edge) + return true + } +} + +@Composable +fun rememberOutOfViewEdges( + blocks: List, + scrollState: ScrollState, + hourPx: Float, +): OutOfViewEdges { + val scope = rememberCoroutineScope() + val edges = remember(scrollState, scope) { OutOfViewEdges(scrollState, scope) } + val density = LocalDensity.current + val reduceMotion = rememberReduceMotion() + val revealSpec = rememberCalendarPageSpec() + SideEffect { + edges.blocks = blocks + edges.hourPx = hourPx + edges.fadeLeadPx = with(density) { EDGE_FADE_LEAD.toPx() } + edges.marginPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx().toFloat() } + edges.reachPx = with(density) { EDGE_TAP_REACH.toPx() } + edges.reduceMotion = reduceMotion + edges.revealSpec = revealSpec + } + return edges +} + +/** + * Thin bars at the top and bottom of a day column's visible viewport, tinted in + * the colour of the nearest timed event hidden past that edge (#188). + * + * Lives inside the column's scrolling content and follows the viewport by + * offsetting against [scrollState] at placement rather than recomposing every + * frame. Opacity follows the gap to the hidden event and reaches zero before + * the event enters view. Taps reach it through [OutOfViewEdges.tap]; screen + * readers get a button. + */ +@Composable +fun BoxScope.OutOfViewEdgeBars( + edges: OutOfViewEdges, + scrollState: ScrollState, + dark: Boolean, +) { + val touchPx = with(LocalDensity.current) { EDGE_TOUCH_HEIGHT.roundToPx() } + val events = edges.events + EdgeBar( + block = events.above, + edge = ViewportEdge.Top, + dark = dark, + onReveal = edges::reveal, + scrollState = scrollState, + hourPx = edges.hourPx, + fadeLeadPx = edges.fadeLeadPx, + modifier = Modifier + .align(Alignment.TopStart) + .offset { IntOffset(0, scrollState.value) }, + ) + EdgeBar( + block = events.below, + edge = ViewportEdge.Bottom, + dark = dark, + onReveal = edges::reveal, + scrollState = scrollState, + hourPx = edges.hourPx, + fadeLeadPx = edges.fadeLeadPx, + modifier = Modifier + .align(Alignment.TopStart) + .offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) }, + ) +} + +@Composable +private fun EdgeBar( + block: TimedBlock?, + edge: ViewportEdge, + dark: Boolean, + onReveal: (ViewportEdge) -> Unit, + scrollState: ScrollState, + hourPx: Float, + fadeLeadPx: Float, + modifier: Modifier = Modifier, +) { + block ?: return + val color = eventFill(block.event.color, dark, LocalSoftenColors.current) + val label = stringResource( + if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events, + ) + Box( + modifier = modifier + .fillMaxWidth() + .height(EDGE_TOUCH_HEIGHT) + .graphicsLayer { + val gapPx = when (edge) { + ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx + ViewportEdge.Bottom -> block.startMin / 60f * hourPx - + (scrollState.value + scrollState.viewportSize) + } + alpha = edgeBarAlpha(gapPx, fadeLeadPx) + } + .semantics { + role = Role.Button + contentDescription = label + onClick { + onReveal(edge) + true + } + }, + contentAlignment = if (edge == ViewportEdge.Top) Alignment.TopCenter else Alignment.BottomCenter, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = EDGE_BAR_HORIZONTAL_INSET, + vertical = EDGE_BAR_VERTICAL_INSET, + ) + .height(EDGE_BAR_THICKNESS) + .background(color, CircleShape), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 13b2ef26..3544cd02 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -106,6 +106,8 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.MoveTarget import de.jeanlucmakiola.calendula.ui.common.NowLine +import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdgeBars +import de.jeanlucmakiola.calendula.ui.common.rememberOutOfViewEdges import de.jeanlucmakiola.calendula.ui.common.TimelineDragController import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay import de.jeanlucmakiola.calendula.ui.common.TimelineDrop @@ -707,6 +709,7 @@ private fun Timeline( date = state.date, today = state.today, hourHeight = hourHeight, + scrollState = scrollState, dragController = dragController, onEventClick = onEventClick, onCreateAt = onCreateAt, @@ -742,6 +745,7 @@ private fun DayColumnCard( date: LocalDate, today: LocalDate, hourHeight: Dp, + scrollState: ScrollState, dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, @@ -761,6 +765,8 @@ private fun DayColumnCard( // them, or taps land on the scale the column had before the zoom (#148). val currentHourPx = rememberUpdatedState(hourPx) val currentOnCreateAt = rememberUpdatedState(onCreateAt) + val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx) + val currentEdges = rememberUpdatedState(edges) Card( // Plain rectangular column — the soft corners come from the outer // rounded scroll viewport, so inner rounding would look odd at the edges. @@ -788,6 +794,7 @@ private fun DayColumnCard( // only fires on the column background. Snaps to the tapped hour. .pointerInput(date) { detectTapGestures { offset -> + if (currentEdges.value.tap(offset.y)) return@detectTapGestures currentOnCreateAt.value( date, tappedMinuteOfDay(offset.y, currentHourPx.value), @@ -849,6 +856,11 @@ private fun DayColumnCard( if (date == today) { NowLine(date = date, hourHeight = hourHeight) } + OutOfViewEdgeBars( + edges = edges, + scrollState = scrollState, + dark = dark, + ) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 7cee5eef..8d157a55 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -132,6 +132,8 @@ import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.NowLine +import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdgeBars +import de.jeanlucmakiola.calendula.ui.common.rememberOutOfViewEdges import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec @@ -997,6 +999,7 @@ private fun Timeline( date = day, today = state.today, hourHeight = hourHeight, + scrollState = scrollState, dragController = dragController, onEventClick = onEventClick, onCreateAt = onCreateAt, @@ -1018,6 +1021,7 @@ private fun DayColumnCard( date: LocalDate, today: LocalDate, hourHeight: Dp, + scrollState: ScrollState, dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, @@ -1037,6 +1041,8 @@ private fun DayColumnCard( // them, or taps land on the scale the column had before the zoom (#148). val currentHourPx = rememberUpdatedState(hourPx) val currentOnCreateAt = rememberUpdatedState(onCreateAt) + val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx) + val currentEdges = rememberUpdatedState(edges) Card( // Plain rectangular columns — the soft corners come from the outer // rounded scroll viewport, so inner rounding would look odd at the edges. @@ -1064,6 +1070,7 @@ private fun DayColumnCard( // blocks are consumed by their own handler first. Snaps to hour. .pointerInput(date) { detectTapGestures { offset -> + if (currentEdges.value.tap(offset.y)) return@detectTapGestures currentOnCreateAt.value( date, tappedMinuteOfDay(offset.y, currentHourPx.value), @@ -1125,6 +1132,11 @@ private fun DayColumnCard( if (date == today) { NowLine(date = date, hourHeight = hourHeight) } + OutOfViewEdgeBars( + edges = edges, + scrollState = scrollState, + dark = dark, + ) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82e8ca80..f1dcca08 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -67,6 +67,10 @@ Today + + Earlier events + Later events + Back Edit diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt index caed3b22..0446a90f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt @@ -156,4 +156,5 @@ class EventToneTest { assertThat(eventTone(0xFFFFF6C0.toInt(), dark = false, harmonise = false).onContainer) .isEqualTo(0xFF000000.toInt()) } + } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt new file mode 100644 index 00000000..6268b26f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt @@ -0,0 +1,147 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.week.TimedBlock +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +/** Which events a column's edge bars point at, and where a tap scrolls (#188). */ +class OutOfViewEdgesTest { + + private fun block(id: Long, startMin: Int, endMin: Int) = TimedBlock( + event = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "Event $id", + start = Instant.fromEpochMilliseconds(0), + end = Instant.fromEpochMilliseconds(0), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ), + startMin = startMin, + endMin = endMin, + lane = 0, + laneCount = 1, + ) + + private val early = block(1, 7 * 60, 8 * 60) + private val breakfast = block(2, 8 * 60, 9 * 60) + private val lunch = block(3, 12 * 60, 13 * 60) + private val dinner = block(4, 19 * 60, 20 * 60) + private val late = block(5, 21 * 60, 22 * 60) + + // Viewport 10:00–17:00. + private fun outOf(vararg blocks: TimedBlock) = + outOfViewEvents(blocks.toList(), topMin = 600f, bottomMin = 1020f) + + @Test + fun `the nearest hidden event on each side is picked`() { + val events = outOf(early, breakfast, lunch, dinner, late) + assertThat(events.above).isEqualTo(breakfast) + assertThat(events.below).isEqualTo(dinner) + } + + @Test + fun `an event in view points nowhere`() { + assertThat(outOf(lunch)).isEqualTo(OutOfViewEvents.None) + } + + @Test + fun `an event cut by the edge is not out of view`() { + val straddling = block(6, 9 * 60, 11 * 60) + assertThat(outOf(straddling).above).isNull() + } + + @Test + fun `an event disappears from the edge as soon as a sliver enters view`() { + val sliver = block(6, 9 * 60, 10 * 60 + 5) + assertThat(outOf(sliver).above).isNull() + val tip = block(7, 17 * 60 - 5, 18 * 60) + assertThat(outOf(tip).below).isNull() + val justAbove = block(8, 9 * 60, 10 * 60) + val justBelow = block(9, 17 * 60, 18 * 60) + assertThat(outOf(justAbove).above).isEqualTo(justAbove) + assertThat(outOf(justBelow).below).isEqualTo(justBelow) + } + + @Test + fun `the bar fades before its event reaches the viewport`() { + assertThat(edgeBarAlpha(gapPx = 24f, fadeLeadPx = 24f)).isEqualTo(1f) + assertThat(edgeBarAlpha(gapPx = 12f, fadeLeadPx = 24f)).isEqualTo(0.5f) + assertThat(edgeBarAlpha(gapPx = 0f, fadeLeadPx = 24f)).isEqualTo(0f) + assertThat(edgeBarAlpha(gapPx = -1f, fadeLeadPx = 24f)).isEqualTo(0f) + } + + @Test + fun `a column scrolled to its top points at nothing above it`() { + val first = block(6, 0, 30) + val events = outOfViewEvents(listOf(first), topMin = 0f, bottomMin = 420f) + assertThat(events.above).isNull() + } + + @Test + fun `a short event is judged by the height it renders at`() { + // Five minutes ending at 09:58 still draws 26 minutes tall, past 10:00. + val blip = block(6, 9 * 60 + 53, 9 * 60 + 58) + assertThat(outOf(blip).above).isNull() + } + + @Test + fun `an event above scrolls to its start less the margin`() { + val target = revealScrollTarget(breakfast, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700) + assertThat(target).isEqualTo(780) + } + + @Test + fun `an event below that fits lands whole at the bottom`() { + val target = revealScrollTarget(dinner, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700) + // Its end, 2000, plus the margin, sits at the viewport's bottom. + assertThat(target).isEqualTo(1320) + } + + @Test + fun `an event below taller than the viewport lands with its start at the top`() { + val long = block(6, 12 * 60, 22 * 60) + val target = revealScrollTarget(long, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700) + assertThat(target).isEqualTo(1180) + } + + @Test + fun `the target stays within the scroll range`() { + val first = block(6, 0, 30) + assertThat( + revealScrollTarget(first, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700), + ).isEqualTo(0) + val last = block(7, 23 * 60, 24 * 60) + assertThat( + revealScrollTarget(last, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700), + ).isEqualTo(1700) + } + + // Viewport 1000..1700 px with both bars showing, 40 px of reach. + private fun tapped(y: Float, events: OutOfViewEvents = OutOfViewEvents(breakfast, dinner)) = + tappedEdge(events, y, viewportTop = 1000f, viewportPx = 700, reachPx = 40f) + + @Test + fun `a tap near an edge with a bar hits that bar`() { + assertThat(tapped(1010f)).isEqualTo(ViewportEdge.Top) + assertThat(tapped(1040f)).isEqualTo(ViewportEdge.Top) + assertThat(tapped(1690f)).isEqualTo(ViewportEdge.Bottom) + } + + @Test + fun `a tap past the bar's reach is left to the column`() { + assertThat(tapped(1041f)).isNull() + assertThat(tapped(1350f)).isNull() + assertThat(tapped(1659f)).isNull() + } + + @Test + fun `a tap near an edge without a bar is left to the column`() { + assertThat(tapped(1010f, OutOfViewEvents(null, dinner))).isNull() + assertThat(tapped(1690f, OutOfViewEvents(breakfast, null))).isNull() + } +}