Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
preserving spec order within each group.

### Changed
- TimeCapsule: only earlier entries offer Restore, expand controls have standard 48dp targets,
expansion survives screen recreation during the same recording, and empty history uses the
shared guidance component. The existing timeline rail and change highlighting are preserved.
- Console: compact severity/time/tag rows expand to selectable multiline messages with
horizontal scrolling and Copy log. Search and collapsible level filters retain UI state;
the badge counts hidden levels and enabled noise rules. Newest-first rows follow the latest matching entry’s start
Expand Down
5 changes: 4 additions & 1 deletion devview-timecapsule/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ registry is a list instead: the outgoing capsule is removed from wherever it sit
`LaunchedEffect` and recorded as a new entry. This is intentional — a restore is a state
transition — but it does mean repeated restores grow the timeline. Marked with a
`ponytail:` comment in `ScreenCapsule.kt`.
- **No CompositionLocal.** Unlike `devview-analytics`'s `LocalAnalytics`, `TimeCapsule`'s
- **No CompositionLocal.** `TimeCapsule`'s
registry is read directly from the object inside `TimeCapsuleScreen`. There's exactly
one consumer (the module's own screen) and no host-app use case for reading the registry
elsewhere, so the extra indirection isn't justified here.
Expand All @@ -84,6 +84,9 @@ registry is a list instead: the outgoing capsule is removed from wherever it sit
showing itself verbatim, unchanged from the original behavior. Expanding a row reveals the
full label below a divider, with the changed values highlighted — this is also the only way
to read a label that's been truncated at two lines.
- **Timeline controls.** Only earlier entries offer Restore. Expand uses the standard 48dp
IconButton target; expanded entry IDs survive screen recreation and reset when the active
capsule changes. Empty history uses the shared `DevViewEmptyState`.
- **Header subtitle defaults to the owner's class name.** `TimeCapsuleEffect`'s `subtitle`
parameter is shown as "Recording {subtitle}" above the timeline, so a tester can confirm
which screen they're looking at. Defaulting to `owner::class.simpleName` (rather than
Expand Down
1 change: 1 addition & 0 deletions devview-timecapsule/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.convention.multiplatform.library)
alias(libs.plugins.convention.compose.multiplatform)
alias(libs.plugins.convention.unitTest)
alias(libs.plugins.convention.deviceTest)
alias(libs.plugins.convention.kover)
alias(libs.plugins.convention.metalava)
alias(libs.plugins.dokka)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package com.worldline.devview.timecapsule

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.ui.test.StateRestorationTester
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertWidthIsAtLeast
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.v2.runComposeUiTest
import androidx.compose.ui.unit.dp
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Test

class TimeCapsuleScreenTest {
@Test
fun current_entry_has_no_restore_but_earlier_entries_restore_the_owner() = runComposeUiTest {
val owner = FakeOwner()
val capsule = capsule(owner = owner)
TimeCapsule.register(capsule = capsule)
try {
setContent { MaterialTheme { TimeCapsuleScreen() } }
onNode(
matcher = hasText(text = "Restore") and hasAnyAncestor(
matcher = hasTestTag(testTag = "time_capsule_row_1")
)
).assertDoesNotExist()
onNode(
matcher = hasText(text = "Restore") and hasAnyAncestor(
matcher = hasTestTag(testTag = "time_capsule_row_0")
)
).assertIsDisplayed().performClick()
runOnIdle { owner.state.value shouldBe 1 }
} finally {
TimeCapsule.unregister(capsule = capsule)
}
}

@Test
fun expand_control_has_a_48dp_target_and_toggles_details() = runComposeUiTest {
val capsule = capsule()
TimeCapsule.register(capsule = capsule)
try {
setContent { MaterialTheme { TimeCapsuleScreen() } }
onNodeWithTag(testTag = "time_capsule_expand_1")
.assertHeightIsAtLeast(expectedMinHeight = 48.dp)
.assertWidthIsAtLeast(expectedMinWidth = 48.dp)
.performClick()
onNodeWithContentDescription(label = "Collapse").assertIsDisplayed().performClick()
onNodeWithContentDescription(label = "Expand").assertIsDisplayed()
} finally {
TimeCapsule.unregister(capsule = capsule)
}
}

@Test
fun expanded_entry_survives_saved_state_restoration() = runComposeUiTest {
val capsule = capsule()
TimeCapsule.register(capsule = capsule)
try {
val restoration = StateRestorationTester(this)
restoration.setContent { MaterialTheme { TimeCapsuleScreen() } }
onNodeWithTag(testTag = "time_capsule_expand_1").performClick()
onNodeWithContentDescription(label = "Collapse").assertIsDisplayed()
restoration.emulateSaveAndRestore()
onNodeWithContentDescription(label = "Collapse").assertIsDisplayed()
onNodeWithText(text = "Counter(count=2)").assertIsDisplayed()
} finally {
TimeCapsule.unregister(capsule = capsule)
}
}

@Test
fun a_new_recording_does_not_inherit_expansion_for_reused_entry_ids() = runComposeUiTest {
val first = capsule()
val second = capsule()
TimeCapsule.register(capsule = first)
try {
setContent { MaterialTheme { TimeCapsuleScreen() } }
onNodeWithTag(testTag = "time_capsule_expand_1").performClick()
onNodeWithContentDescription(label = "Collapse").assertIsDisplayed()
runOnIdle {
TimeCapsule.register(capsule = second)
TimeCapsule.unregister(capsule = first)
}
onNodeWithContentDescription(label = "Expand").assertIsDisplayed()
onNodeWithText(text = "Counter(count=2)").assertDoesNotExist()
} finally {
TimeCapsule.unregister(capsule = first)
TimeCapsule.unregister(capsule = second)
}
}

@Test
fun returning_after_a_recording_changed_does_not_restore_unrelated_expansion() = runComposeUiTest {
val first = capsule()
val second = capsule()
val showScreen = mutableStateOf(value = true)
TimeCapsule.register(capsule = first)
try {
setContent {
val holder = rememberSaveableStateHolder()
if (showScreen.value) {
holder.SaveableStateProvider(key = "timeline") {
MaterialTheme { TimeCapsuleScreen() }
}
}
}
onNodeWithTag(testTag = "time_capsule_expand_1").performClick()
onNodeWithContentDescription(label = "Collapse").assertIsDisplayed()
runOnIdle { showScreen.value = false }
runOnIdle {
TimeCapsule.unregister(capsule = first)
TimeCapsule.register(capsule = second)
showScreen.value = true
}
onNodeWithContentDescription(label = "Expand").assertIsDisplayed()
onNodeWithText(text = "Counter(count=2)").assertDoesNotExist()
} finally {
TimeCapsule.unregister(capsule = first)
TimeCapsule.unregister(capsule = second)
}
}

@Test
fun restored_long_free_form_label_can_still_be_collapsed() = runComposeUiTest {
val capsule = ScreenCapsule(
owner = FakeOwner(),
label = { "A long free-form state description ".repeat(n = 30) },
maxEntries = 10
).apply { record(state = 1) }
TimeCapsule.register(capsule = capsule)
try {
val restoration = StateRestorationTester(this)
restoration.setContent { MaterialTheme { TimeCapsuleScreen() } }
onNodeWithTag(testTag = "time_capsule_expand_0").performClick()
restoration.emulateSaveAndRestore()
onNodeWithContentDescription(label = "Collapse").performScrollTo().performClick()
onNodeWithContentDescription(label = "Expand").assertIsDisplayed()
} finally {
TimeCapsule.unregister(capsule = capsule)
}
}

@Test
fun ordinary_recomposition_keeps_expansion() = runComposeUiTest {
val capsule = capsule()
val bottomPadding = mutableStateOf(value = 0.dp)
TimeCapsule.register(capsule = capsule)
try {
setContent { MaterialTheme { TimeCapsuleScreen(bottomPadding = bottomPadding.value) } }
onNodeWithTag(testTag = "time_capsule_expand_1").performClick()
runOnIdle { bottomPadding.value = 16.dp }
onNodeWithContentDescription(label = "Collapse").assertIsDisplayed()
} finally {
TimeCapsule.unregister(capsule = capsule)
}
}

@Test
fun empty_recording_explains_when_states_will_appear() = runComposeUiTest {
val capsule = ScreenCapsule(owner = FakeOwner(), label = { it.toString() }, maxEntries = 10)
TimeCapsule.register(capsule = capsule)
try {
setContent { MaterialTheme { TimeCapsuleScreen() } }
onNodeWithText(text = "No states captured yet").assertIsDisplayed()
onNodeWithText(
text = "State changes will appear here while this screen is recording."
).assertIsDisplayed()
} finally {
TimeCapsule.unregister(capsule = capsule)
}
}

@Test
fun no_recording_explains_how_to_start() = runComposeUiTest {
setContent { MaterialTheme { TimeCapsuleScreen() } }
onNodeWithText(text = "No screen is recording").assertIsDisplayed()
onNodeWithText(
text = "Add TimeCapsuleEffect(owner) to a screen to record its state history."
).assertIsDisplayed()
}

private fun capsule(owner: FakeOwner = FakeOwner()): ScreenCapsule<Int> = ScreenCapsule(
owner = owner,
label = { "Counter(count=$it)" },
maxEntries = 10
).apply {
record(state = 1)
record(state = 2)
}

private class FakeOwner : TimeCapsuleOwner<Int> {
override val state = MutableStateFlow(value = 2)

override fun restoreState(state: Int) {
this.state.value = state
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshotFlow
import kotlin.reflect.KProperty1
import kotlin.time.Clock
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import kotlinx.coroutines.flow.collectLatest

/** A single recorded state, labelled and timestamped at the moment it was captured. */
Expand Down Expand Up @@ -36,6 +38,9 @@ internal class ScreenCapsule<S : Any>(
}
}

@OptIn(ExperimentalUuidApi::class)
val recordingId: String = Uuid.random().toString()

private val recordedEntries = mutableStateListOf<Recorded<S>>()
private var nextId = 0L

Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,34 @@
package com.worldline.devview.timecapsule

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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.material.icons.Icons
import androidx.compose.material.icons.rounded.History
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.worldline.devview.timecapsule.components.TimeCapsuleRow
import com.worldline.devview.ui.DevViewEmptyState

/**
* Screen showing the state history of whichever screen is currently recording via
* [TimeCapsuleEffect], newest entry first, with a restore action on each row.
* [TimeCapsuleEffect], newest entry first, with restore actions on earlier entries.
*
* @param modifier Modifier to be applied to the root container.
* @param bottomPadding Additional bottom padding applied to the list, used to avoid overlap
Expand All @@ -43,7 +41,21 @@ import com.worldline.devview.timecapsule.components.TimeCapsuleRow
internal fun TimeCapsuleScreen(modifier: Modifier = Modifier, bottomPadding: Dp = 0.dp) {
val capsule = TimeCapsule.current
val entries = capsule?.entries.orEmpty().asReversed()
var expandedIds by remember { mutableStateOf(value = emptySet<Long>()) }
val recordingId = capsule?.recordingId.orEmpty()
var expandedIds by rememberSaveable(
recordingId,
stateSaver = listSaver<List<Long>, Any>(
save = { listOf(element = recordingId) + it },
Comment on lines +45 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Saved expansion keeps growing

expandedIds now goes into saved state, but it keeps IDs after their entries leave the timeline. A long-running recording can therefore save an ever-growing list even though maxEntries bounds the history. This wastes memory and increases the work needed to save the screen. Filter expansion against retained entry IDs when history changes, including after Clear.

// Saved inputs are not validated when a new composition restores its state.
restore = { saved ->
if (saved.firstOrNull() == recordingId) {
saved.drop(n = 1).map { it as Long }
} else {
emptyList()
}
}
)
) { mutableStateOf(value = emptyList<Long>()) }

Column(modifier = modifier.fillMaxSize()) {
capsule?.subtitle?.let { subtitle ->
Expand All @@ -58,30 +70,28 @@ internal fun TimeCapsuleScreen(modifier: Modifier = Modifier, bottomPadding: Dp

if (entries.isEmpty()) {
Box(
modifier = Modifier.weight(weight = 1f).fillMaxWidth().padding(all = 32.dp),
modifier = Modifier
.weight(
weight = 1f
).fillMaxWidth()
.padding(bottom = bottomPadding),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(space = 8.dp)
) {
Icon(
imageVector = Icons.Rounded.History,
contentDescription = null,
modifier = Modifier.size(size = 48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = if (capsule == null) {
"No screen is recording. Add TimeCapsuleEffect(owner) to a screen."
} else {
"No states captured yet."
},
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
}
DevViewEmptyState(
icon = Icons.Rounded.History,
title = if (capsule ==
null
) {
"No screen is recording"
} else {
"No states captured yet"
},
message = if (capsule == null) {
"Add TimeCapsuleEffect(owner) to a screen to record its state history."
} else {
"State changes will appear here while this screen is recording."
}
)
}
return@Column
}
Expand Down
Loading
Loading