Observe frame-zero cuts and keep picker masks opaque
This commit is contained in:
parent
612ae79f0c
commit
b4742bed6d
3 changed files with 116 additions and 38 deletions
|
|
@ -82,24 +82,34 @@ class HomeHeroStartingTest {
|
||||||
|
|
||||||
private fun successfulStart(reduced: Boolean) {
|
private fun successfulStart(reduced: Boolean) {
|
||||||
mount(reduced)
|
mount(reduced)
|
||||||
val width = rule.onNode(hasText("Resume", substring = true) and hasClickAction()).fetchSemanticsNode().boundsInRoot.width
|
|
||||||
press()
|
|
||||||
rule.mainClock.advanceTimeBy(32)
|
|
||||||
rule.onNodeWithText("Starting…").assertIsDisplayed()
|
|
||||||
assertEquals(width, rule.onNode(hasText("Starting…") and hasClickAction()).fetchSemanticsNode().boundsInRoot.width, 0f)
|
|
||||||
press()
|
|
||||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
|
||||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
|
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath)
|
||||||
|
val width = rule.onNode(hasText("Resume", substring = true) and hasClickAction()).fetchSemanticsNode().boundsInRoot.width
|
||||||
val samples = mutableListOf<String>()
|
val samples = mutableListOf<String>()
|
||||||
fun frame(phase: String, index: Int): Int {
|
fun frame(phase: String, index: Int): Int {
|
||||||
val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png")
|
val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png")
|
||||||
|
val clock = rule.mainClock.currentTime
|
||||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals("Native capture cannot warm the animation clock", clock, rule.mainClock.currentTime)
|
||||||
val image = BitmapFactory.decodeFile(file.absolutePath)
|
val image = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
val pixel = try { image.getPixel(600, 500) } finally { image.recycle() }
|
val pixel = try { image.getPixel(600, 500) } finally { image.recycle() }
|
||||||
samples.add(phase + "," + index * 16 + "," + pixel + "," + navigation.backStack.size)
|
samples.add(phase + "," + index * 16 + "," + pixel + "," + navigation.backStack.size)
|
||||||
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||||
}
|
}
|
||||||
repeat(13) { frame("home-start", it); rule.mainClock.advanceTimeByFrame() }
|
press()
|
||||||
|
val origin = rule.mainClock.currentTime
|
||||||
|
repeat(14) { index ->
|
||||||
|
if (index > 0) rule.mainClock.advanceTimeByFrame()
|
||||||
|
frame("home-start", index)
|
||||||
|
assertEquals("Every frame after OK is observed, including frame zero", origin + index * 16L, rule.mainClock.currentTime)
|
||||||
|
if (index >= 2) {
|
||||||
|
rule.onNodeWithText("Starting…").assertIsDisplayed()
|
||||||
|
assertEquals(width, rule.onNode(hasText("Starting…") and hasClickAction()).fetchSemanticsNode().boundsInRoot.width, 0f)
|
||||||
|
}
|
||||||
|
if (index == 3) press()
|
||||||
|
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||||
|
}
|
||||||
val id = UUID.randomUUID()
|
val id = UUID.randomUUID()
|
||||||
release.complete(BaseItemDto(id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE))
|
release.complete(BaseItemDto(id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE))
|
||||||
await { session.playback.launch != null }
|
await { session.playback.launch != null }
|
||||||
|
|
@ -129,12 +139,20 @@ class HomeHeroStartingTest {
|
||||||
|
|
||||||
@Test fun failedPreparationRestoresResumeAndShowsTheExistingRecoveryToast() {
|
@Test fun failedPreparationRestoresResumeAndShowsTheExistingRecoveryToast() {
|
||||||
mount()
|
mount()
|
||||||
|
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/failure").apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath)
|
||||||
press()
|
press()
|
||||||
rule.mainClock.advanceTimeBy(200)
|
repeat(14) { index ->
|
||||||
rule.onNodeWithText("Starting…").assertIsDisplayed()
|
if (index > 0) rule.mainClock.advanceTimeByFrame()
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "preparing-" + index.toString().padStart(2, '0') + ".png").absolutePath)
|
||||||
|
if (index >= 2) rule.onNodeWithText("Starting…").assertIsDisplayed()
|
||||||
|
}
|
||||||
release.completeExceptionally(IOException("offline"))
|
release.completeExceptionally(IOException("offline"))
|
||||||
await { model.toast != null }
|
await { model.toast != null }
|
||||||
rule.mainClock.advanceTimeBy(200)
|
repeat(14) { index ->
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "restored-" + index.toString().padStart(2, '0') + ".png").absolutePath)
|
||||||
|
}
|
||||||
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
|
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
|
||||||
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
|
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
|
||||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||||
|
|
@ -143,6 +161,7 @@ class HomeHeroStartingTest {
|
||||||
@Test fun leavingHomeCancelsPreparationAndCannotLaunchALateResult() {
|
@Test fun leavingHomeCancelsPreparationAndCannotLaunchALateResult() {
|
||||||
mount()
|
mount()
|
||||||
press()
|
press()
|
||||||
|
assertEquals("Leaving Home must cancel an actual pending start", "hero-play", session.playback.starting)
|
||||||
rule.mainClock.advanceTimeBy(32)
|
rule.mainClock.advanceTimeBy(32)
|
||||||
rule.runOnIdle { visible.value = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
rule.runOnIdle { visible.value = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
rule.mainClock.advanceTimeBy(32)
|
rule.mainClock.advanceTimeBy(32)
|
||||||
|
|
@ -153,12 +172,14 @@ class HomeHeroStartingTest {
|
||||||
assertNull(session.playback.launch)
|
assertNull(session.playback.launch)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun press() = rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) }
|
private fun press() {
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) }
|
||||||
|
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
|
}
|
||||||
private fun await(condition: () -> Boolean) {
|
private fun await(condition: () -> Boolean) {
|
||||||
rule.waitUntil(5000) {
|
rule.waitUntil(5000) {
|
||||||
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
||||||
shadowOf(Looper.getMainLooper()).idle()
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
if (!rule.mainClock.autoAdvance) rule.mainClock.advanceTimeByFrame()
|
|
||||||
condition()
|
condition()
|
||||||
}
|
}
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.foundation.layout.requiredSize
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
|
@ -33,7 +35,7 @@ import org.robolectric.Shadows.shadowOf
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
/** Each navigation change retires the outgoing shell in the first rendered frame. */
|
/** Every observed native frame has one opaque screen; the incoming scene must compose within 16 ms. */
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
|
@ -57,19 +59,45 @@ class NavigationCutTest {
|
||||||
}
|
}
|
||||||
fun strip(phase: String) {
|
fun strip(phase: String) {
|
||||||
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
|
val origin = rule.mainClock.currentTime
|
||||||
|
var firstIncoming: Int? = null
|
||||||
|
var opaqueAvatar = 0
|
||||||
|
try {
|
||||||
repeat(21) { index ->
|
repeat(21) { index ->
|
||||||
rule.mainClock.advanceTimeByFrame()
|
if (index > 0) rule.mainClock.advanceTimeByFrame()
|
||||||
val clock = rule.mainClock.currentTime
|
val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png")
|
||||||
rule.onRoot().captureRoboImage(File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png").absolutePath)
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
assertEquals("Native drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime)
|
rule.waitForIdle()
|
||||||
val shells = rule.onAllNodesWithTag("wolfskin-shell").fetchSemanticsNodes().size
|
val shells = rule.onAllNodesWithTag("wolfskin-shell").fetchSemanticsNodes().size
|
||||||
val titles = rule.onAllNodesWithTag("title-page").fetchSemanticsNodes().size
|
val titles = rule.onAllNodesWithTag("title-page").fetchSemanticsNodes().size
|
||||||
val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size
|
val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size
|
||||||
assertEquals("Only the live destination may own a nav bar", 1, shells)
|
val libraries = rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().size
|
||||||
assertEquals(if (phase == "open-title") 1 else 0, titles)
|
val image = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
if (phase != "back") assertEquals("Outgoing Home cards cannot survive the cut", 0, cards)
|
val avatar = try { image.getPixel(1774, 90) } finally { image.recycle() }
|
||||||
else assertTrue("Back immediately paints Home's shelves", cards > 0)
|
samples.add("$phase,${index * 16},$shells,$titles,$cards,$libraries,$avatar")
|
||||||
samples.add(phase + "," + index * 16 + "," + shells + "," + titles + "," + cards)
|
assertEquals("Observing a native frame cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime)
|
||||||
|
assertEquals("Exactly one destination owns the nav bar in every frame", 1, shells)
|
||||||
|
assertEquals("No overlap or blank destination during the cut", 1, titles + libraries + if (cards > 0) 1 else 0)
|
||||||
|
if (index == 0) opaqueAvatar = avatar
|
||||||
|
assertEquals("The persistent avatar cannot acquire whole-screen transition alpha", opaqueAvatar, avatar)
|
||||||
|
val incoming = when (phase) {
|
||||||
|
"open-title" -> titles == 1
|
||||||
|
"back" -> cards > 0
|
||||||
|
else -> libraries == 1
|
||||||
|
}
|
||||||
|
if (incoming && firstIncoming == null) {
|
||||||
|
firstIncoming = index
|
||||||
|
assertTrue("$phase first composed incoming after $index frames (${index * 16} ms), not within one frame", index <= 1)
|
||||||
|
}
|
||||||
|
if (firstIncoming != null) {
|
||||||
|
assertTrue("The incoming destination cannot disappear again", incoming)
|
||||||
|
if (phase != "back") assertEquals("No outgoing Home card survives the first incoming frame", 0, cards)
|
||||||
|
else assertEquals("Back cannot retain the title page", 0, titles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNotNull("Incoming destination did not appear within the 320 ms strip", firstIncoming)
|
||||||
|
} finally {
|
||||||
|
File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards,libraries,avatarPixel\n" + samples.joinToString("\n"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L); pressKey(Key.DirectionCenter, 0L) }
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L); pressKey(Key.DirectionCenter, 0L) }
|
||||||
|
|
@ -78,7 +106,6 @@ class NavigationCutTest {
|
||||||
strip("back")
|
strip("back")
|
||||||
rule.runOnIdle { host.session.state!!.focusNav(2); host.session.state!!.key(ShellKey.Ok) }
|
rule.runOnIdle { host.session.state!!.focusNav(2); host.session.state!!.key(ShellKey.Ok) }
|
||||||
strip("tab")
|
strip("tab")
|
||||||
File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards\n" + samples.joinToString("\n"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun playbackVeilIgnoresKeysAndBackThenReturnsBackOwnershipToHome() {
|
@Test fun playbackVeilIgnoresKeysAndBackThenReturnsBackOwnershipToHome() {
|
||||||
|
|
@ -119,6 +146,7 @@ class NavigationCutTest {
|
||||||
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
|
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
|
||||||
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null)
|
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null)
|
||||||
val stack = mutableStateListOf<SetupDestination>(SetupDestination.ServerList)
|
val stack = mutableStateListOf<SetupDestination>(SetupDestination.ServerList)
|
||||||
|
val colors = mapOf("ServerList" to 0xff304060.toInt(), "UserList" to 0xff406030.toInt(), "AppContent" to 0xff603040.toInt())
|
||||||
val screensaver = mockk<ScreensaverService>()
|
val screensaver = mockk<ScreensaverService>()
|
||||||
every { screensaver.state } returns kotlinx.coroutines.flow.MutableStateFlow(ScreensaverState(false, false, false, false))
|
every { screensaver.state } returns kotlinx.coroutines.flow.MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { androidx.tv.material3.MaterialTheme {
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { androidx.tv.material3.MaterialTheme {
|
||||||
|
|
@ -126,21 +154,49 @@ class NavigationCutTest {
|
||||||
mockk(relaxed = true), screensaver,
|
mockk(relaxed = true), screensaver,
|
||||||
Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight),
|
Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight),
|
||||||
setupInterceptor = { destination, modifier ->
|
setupInterceptor = { destination, modifier ->
|
||||||
androidx.tv.material3.Text(destination.javaClass.simpleName, modifier)
|
androidx.compose.foundation.layout.Box(modifier.then(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
|
||||||
|
.then(Modifier.background(androidx.compose.ui.graphics.Color(colors.getValue(destination.javaClass.simpleName))))) {
|
||||||
|
androidx.tv.material3.Text(destination.javaClass.simpleName)
|
||||||
|
}
|
||||||
true
|
true
|
||||||
})
|
})
|
||||||
} } }
|
} } }
|
||||||
rule.mainClock.autoAdvance = false
|
rule.mainClock.autoAdvance = false
|
||||||
|
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() }
|
||||||
|
val samples = mutableListOf<String>()
|
||||||
for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) {
|
for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) {
|
||||||
val previous = stack.single().javaClass.simpleName
|
val previous = stack.single().javaClass.simpleName
|
||||||
|
val next = destination.javaClass.simpleName
|
||||||
rule.runOnIdle { stack[0] = destination; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
rule.runOnIdle { stack[0] = destination; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
rule.mainClock.advanceTimeByFrame()
|
val origin = rule.mainClock.currentTime
|
||||||
val clock = rule.mainClock.currentTime
|
var firstIncoming: Int? = null
|
||||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() }
|
try {
|
||||||
rule.onRoot().captureRoboImage(File(directory, destination.javaClass.simpleName + ".png").absolutePath)
|
repeat(21) { index ->
|
||||||
assertEquals("Native setup drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime)
|
if (index > 0) rule.mainClock.advanceTimeByFrame()
|
||||||
|
val file = File(directory, next + "-" + index.toString().padStart(2, '0') + ".png")
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
rule.waitForIdle()
|
||||||
|
val counts = colors.keys.associateWith { rule.onAllNodesWithText(it).fetchSemanticsNodes().size }
|
||||||
|
val image = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
val pixel = try { image.getPixel(600, 500) } finally { image.recycle() }
|
||||||
|
samples.add("$next,${index * 16},${counts.getValue(previous)},${counts.getValue(next)},$pixel")
|
||||||
|
assertEquals("Native setup observation cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime)
|
||||||
|
assertEquals("One setup screen per observed frame, never both or blank", 1, counts.values.sum())
|
||||||
|
val incoming = counts.getValue(next) == 1
|
||||||
|
assertEquals("Setup screens are opaque, not intermediate-alpha composites", colors.getValue(if (incoming) next else previous), pixel)
|
||||||
|
if (incoming && firstIncoming == null) {
|
||||||
|
firstIncoming = index
|
||||||
|
assertTrue("$next first composed after $index frames (${index * 16} ms), not within one frame", index <= 1)
|
||||||
|
}
|
||||||
|
if (firstIncoming != null) {
|
||||||
|
assertTrue("The incoming setup screen cannot disappear", incoming)
|
||||||
rule.onNodeWithText(previous).assertDoesNotExist()
|
rule.onNodeWithText(previous).assertDoesNotExist()
|
||||||
rule.onAllNodesWithText(destination.javaClass.simpleName).assertCountEquals(1)
|
}
|
||||||
|
}
|
||||||
|
assertNotNull("Setup screen did not appear within 320 ms", firstIncoming)
|
||||||
|
} finally {
|
||||||
|
File(directory, "samples.csv").writeText("destination,timeMs,previous,incoming,pixel\n" + samples.joinToString("\n"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,9 @@ fun PersonCommitVeil(commit: PersonCommit, modifier: Modifier = Modifier) {
|
||||||
.drawWithContent {
|
.drawWithContent {
|
||||||
drawContent()
|
drawContent()
|
||||||
val elapsed = commit.elapsedMillis
|
val elapsed = commit.elapsedMillis
|
||||||
|
val full = if (commit.timelineComplete) 1f else if (reduced) elapsed / duration else (elapsed - duration) / duration
|
||||||
|
drawRect(C.bg.copy(alpha = M.ease.transform(full.coerceIn(0f, 1f))))
|
||||||
|
// Paint the opaque phase-one masks last: phase two must not round their background pixels upward.
|
||||||
if (!reduced) {
|
if (!reduced) {
|
||||||
val alpha = M.ease.transform((elapsed / duration).coerceIn(0f, 1f))
|
val alpha = M.ease.transform((elapsed / duration).coerceIn(0f, 1f))
|
||||||
val width = L.pickerAvatar.toPx()
|
val width = L.pickerAvatar.toPx()
|
||||||
|
|
@ -61,8 +64,6 @@ fun PersonCommitVeil(commit: PersonCommit, modifier: Modifier = Modifier) {
|
||||||
size = Size(width + gap, S.pickerGearTop.toPx() - S.pickerPeopleTop.toPx() + gap / 2))
|
size = Size(width + gap, S.pickerGearTop.toPx() - S.pickerPeopleTop.toPx() + gap / 2))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val full = if (commit.timelineComplete) 1f else if (reduced) elapsed / duration else (elapsed - duration) / duration
|
|
||||||
drawRect(C.bg.copy(alpha = M.ease.transform(full.coerceIn(0f, 1f))))
|
|
||||||
}) {
|
}) {
|
||||||
Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker))
|
Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker))
|
||||||
PickerPeople(commit.people, commit.chosenIndex, false, onPerson = null, onGear = null)
|
PickerPeople(commit.people, commit.chosenIndex, false, onPerson = null, onGear = null)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue