From a28feb1a47484996235837c8d69a80f14d1a6ce1 Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Mon, 12 Oct 2026 16:16:17 +0900 Subject: [PATCH] TASK-069: the first screen fades in under the cup instead of gliding Until the session is known the route shown is the repository list; when it becomes Home the change played the full-width tab glide just as the launch cover was fading. That hand-over is now a plain fade, and the cover waits for it (at most 1.5 s longer) before it leaves. Agent: main (opus) Co-Authored-By: Claude Opus 5.5 --- .../java/com/ubisam/ubgitea/MainActivity.kt | 15 ++++++++++++--- .../com/ubisam/ubgitea/ui/components/Motion.kt | 18 +++++++++++++++++- docs/development-log.md | 14 ++++++++++++++ tasks/069-in-app-motion.md | 6 +++++- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index ebec45e..c396566 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -442,7 +442,11 @@ class MainActivity : ComponentActivity() { // screen, so moving between code tabs or pages of one screen does not replay it. val stackDepth = routeStack.size val shownDepth = remember { intArrayOf(stackDepth) } - SideEffect { shownDepth[0] = stackDepth } + val shownReady = remember { booleanArrayOf(sessionReady) } + SideEffect { + shownDepth[0] = stackDepth + shownReady[0] = sessionReady + } AnimatedContent( targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories, modifier = Modifier.fillMaxSize(), @@ -450,7 +454,12 @@ class MainActivity : ComponentActivity() { val fromTab = initialState.tab val toTab = targetState.tab val tabSwitch = stackDepth == shownDepth[0] && fromTab != null && toTab != null - if (tabSwitch) { + if (!shownReady[0]) { + // The session has just been found: the first screen takes the + // waiting one's place where it stands. Nothing was navigated, so + // nothing slides. + fadeIn(tween(200)).togetherWith(fadeOut(tween(90))) + } else if (tabSwitch) { // Tabs sit side by side: the old one glides out as the new one // glides in, the whole width, like turning to the next page. val direction = (toTab.ordinal - fromTab.ordinal).coerceIn(-1, 1) @@ -685,7 +694,7 @@ class MainActivity : ComponentActivity() { }, ) } - SplashOverlay() + SplashOverlay(settled = sessionReady || state.sessionReadyOrNull() != null) } } consumeCallback(intent) diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt b/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt index 809ba57..06bcd3d 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/components/Motion.kt @@ -41,8 +41,10 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -67,6 +69,8 @@ import com.ubisam.ubgitea.ui.theme.UBGiteaTheme import kotlin.math.PI import kotlin.math.sin import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull // The app's moving parts. All of them draw the launcher icon's tea cup, so the app looks like one // thing from the home screen inwards. @@ -310,16 +314,23 @@ fun TabGlide( const val TabGlideMillis = 320 +const val SplashLongestWaitMillis = 1500L + private val SplashGradient = listOf(Color(0xFF4DB0FF), Color(0xFF2F98F9), Color(0xFF0A6FD8)) /** * The launcher icon, enlarged and steaming, over the whole window for the first second after a * cold start. It covers the app rather than delaying it: everything underneath loads meanwhile. + * + * It leaves once [settled] says whether there is a session, so its fade does not share frames + * with the first screen arriving underneath, and never stays longer than [SplashLongestWaitMillis] + * past its own second. */ @Composable -fun SplashOverlay() { +fun SplashOverlay(settled: Boolean) { var finished by rememberSaveable { mutableStateOf(false) } if (finished) return + val settledNow by rememberUpdatedState(settled) val alpha = remember { Animatable(1f) } val scale = remember { Animatable(0.85f) } LaunchedEffect(Unit) { @@ -327,6 +338,11 @@ fun SplashOverlay() { } LaunchedEffect(Unit) { delay(1100) + if (!settledNow) { + withTimeoutOrNull(SplashLongestWaitMillis) { snapshotFlow { settledNow }.first { it } } + // The first screen fades in over the waiting one; let that finish under the cover. + delay(250) + } alpha.animateTo(0f, tween(280)) finished = true } diff --git a/docs/development-log.md b/docs/development-log.md index 9c657bb..fa2e437 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -1615,3 +1615,17 @@ This is the append-only implementation and handoff record shared by Codex and Cl - Caution: published without having been seen on a device, at the product owner's decision. 0.2.3 and older still update through the browser download; the in-app update is first used going from 0.2.4 to whatever comes next, so it stays unproven until then. + +## 2026-10-12 (continued) — TASK-069: the start no longer stutters as the cup leaves + +- Request: the product owner reported that at launch the cup seemed to move off towards the top + left as it disappeared, and that this part stuttered. +- Cause found in the code: until the session is known the route shown is the repository list; + once it is known the route becomes Home. Both are tabs, so the change played the full-width tab + glide, at about the moment the cover was fading, on top of Home's first composition. +- What changed: `MainActivity` gives that hand-over a plain fade and no slide. `SplashOverlay` + takes `settled` and, after its own 1.1 seconds, waits until the session is known (at most 1.5 + seconds more) and for the fade underneath before it leaves. +- Checks and results: the five required checks pass. +- Caution: diagnosed from the code, not seen on a device. If the stutter is still there, the + remaining suspect is the cost of Home's first frames on that phone. diff --git a/tasks/069-in-app-motion.md b/tasks/069-in-app-motion.md index d9be636..ccf4dfa 100644 --- a/tasks/069-in-app-motion.md +++ b/tasks/069-in-app-motion.md @@ -22,6 +22,10 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c 9. Added after that (product owner, 2026-10-12): the tabs inside a screen glide the same way. 전체 저장소/즐겨찾기 and 이슈/풀 리퀘스트 keep their header and tab row still while the list under them changes sides. The branch screen's tabs already slid (a pager) and are unchanged. +10. Reported by the product owner (2026-10-12): the start stuttered as the cup left. The first + screen was gliding in sideways underneath, as if a tab had been switched, while the cover was + fading. The first screen now fades in where it stands, and the cover waits for it (at most + 1.5 seconds longer) before it leaves. ## Scope @@ -46,7 +50,7 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c ## Acceptance criteria -- [x] Each of the nine items above is in the code and the app builds. +- [x] Each of the ten items above is in the code and the app builds. - [x] The splash covers the app without delaying it: sign-in, loading and the OAuth callback run underneath, and taps do not pass through the cover. - [x] Moving between code tabs, or between pages of one screen, does not replay the screen