From db8fc0574e8cea1ff3f3d5f469bd83dd6bd57ff0 Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Mon, 12 Oct 2026 16:38:54 +0900 Subject: [PATCH] TASK-069: screens push in, repository tabs hold their list, tabs stay composed Opening a screen pushes it in from the right and Back reverses it, in place of a 90 ms fade that read as a flicker. The repository tabs keep each tab's last list through a reload, so nothing settles after the glide. The four bottom tabs stay composed; only the one shown answers Back. Agent: main (opus) Co-Authored-By: Claude Opus 5.5 --- .../java/com/ubisam/ubgitea/MainActivity.kt | 16 ++++--- .../repositories/RepositoryListScreen.kt | 42 +++++++++++++++---- .../ubisam/ubgitea/ui/components/Motion.kt | 29 ++++++++++++- docs/development-log.md | 18 ++++++++ tasks/069-in-app-motion.md | 9 +++- 5 files changed, 96 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index f981278..54dad59 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -18,10 +18,12 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -46,6 +48,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier @@ -93,6 +96,7 @@ import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel import com.ubisam.ubgitea.ui.components.ExitConfirmDialog import com.ubisam.ubgitea.ui.components.NoResize +import com.ubisam.ubgitea.ui.components.ScreenPushMillis import com.ubisam.ubgitea.ui.components.SplashOverlay import com.ubisam.ubgitea.ui.components.TabPager import com.ubisam.ubgitea.ui.navigation.Route @@ -439,7 +443,8 @@ class MainActivity : ComponentActivity() { onViewSettings = { push(Route.Settings) }, ) } - // Entering a screen slides it in from the side it was opened towards. Keyed by + // Opening a screen pushes it in from the right as the one behind leaves to the + // left, and going back reverses that; it loads while it arrives. Keyed by // the kind of screen, so moving between code tabs or pages of one screen does // not replay it. The four tabs are one kind: between them the pager below glides. val stackDepth = routeStack.size @@ -453,15 +458,16 @@ class MainActivity : ComponentActivity() { targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories, modifier = Modifier.fillMaxSize(), transitionSpec = { - if (!shownReady[0]) { + val direction = (stackDepth - shownDepth[0]).coerceIn(-1, 1) + if (!shownReady[0] || direction == 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))).using(NoResize) } else { - val direction = (stackDepth - shownDepth[0]).coerceIn(-1, 1) - (fadeIn(tween(200, delayMillis = 50)) + slideInHorizontally(tween(260)) { it / 10 * direction }) - .togetherWith(fadeOut(tween(90))) + val push = tween(ScreenPushMillis, easing = FastOutSlowInEasing) + slideInHorizontally(push) { it * direction } + .togetherWith(slideOutHorizontally(push) { -it * direction }) .using(NoResize) } }, diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt index 8500b3c..6173dff 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -104,8 +105,29 @@ fun RepositoryListScreen( // Changing tab reloads through Loading, which does not say which tab it is loading. Keeping // the tab that was asked for lets the tab row stay where it is while the list under it glides. var requestedMode by remember { mutableStateOf(null) } + // What each tab last showed. A tab change, a refresh and a page turn all pass through + // Loading; showing the last list meanwhile keeps the header and the rows where they are, so + // the new list replaces the old one in place and nothing appears to settle afterwards. + val seen = remember { mutableStateMapOf() } + var seenMode by remember { mutableStateOf(null) } LaunchedEffect(state) { if (state != RepositoryListUiState.Loading) requestedMode = null + when (state) { + is RepositoryListUiState.Content -> { + seen[state.mode] = state + seenMode = state.mode + } + is RepositoryListUiState.Empty -> { + seen[state.mode] = state + seenMode = state.mode + } + // Another account may sign in next: its lists are not these. + RepositoryListUiState.SignedOut -> { + seen.clear() + seenMode = null + } + else -> Unit + } } val showAllRepositories = { requestedMode = RepositoryListMode.ALL @@ -130,11 +152,13 @@ fun RepositoryListScreen( RepositoryListUiState.Loading, is RepositoryListUiState.Empty, is RepositoryListUiState.Content -> { - BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout) - val mode = when (state) { - is RepositoryListUiState.Content -> state.mode - is RepositoryListUiState.Empty -> state.mode - else -> requestedMode + val loadingMode = requestedMode ?: seenMode + val shownState = if (state == RepositoryListUiState.Loading) loadingMode?.let { seen[it] } ?: state else state + BrandTopBar(shownState.accountNameOrNull(), instanceHost, onViewSettings, onLogout) + val mode = when (shownState) { + is RepositoryListUiState.Content -> shownState.mode + is RepositoryListUiState.Empty -> shownState.mode + else -> loadingMode } if (mode == null) { ListSkeleton(label = stringResource(R.string.repositories_loading)) @@ -147,14 +171,14 @@ fun RepositoryListScreen( // No title: the selected tab already says this is 저장소. Column(Modifier.padding(bottom = 12.dp)) { ModeTabs(mode, showAllRepositories, showFavorites) - if (state is RepositoryListUiState.Content && - mode == RepositoryListMode.ALL && state.organizations.isNotEmpty() + if (shownState is RepositoryListUiState.Content && + mode == RepositoryListMode.ALL && shownState.organizations.isNotEmpty() ) { - OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization) + OrganizationFilterRow(shownState.organization, shownState.organizations, onSelectOrganization) } } } - TabGlide(mode to state, { it.first.ordinal }, Modifier.weight(1f).fillMaxWidth()) { (_, shown) -> + TabGlide(mode to shownState, { it.first.ordinal }, Modifier.weight(1f).fillMaxWidth()) { (_, shown) -> Column(Modifier.fillMaxSize()) { when (shown) { is RepositoryListUiState.Content -> RepositoryContent( 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 4a45ca5..947f5a7 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 @@ -1,5 +1,8 @@ package com.ubisam.ubgitea.ui.components +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.Animatable @@ -40,6 +43,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -69,6 +73,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LocalLifecycleOwner import com.ubisam.ubgitea.ui.theme.UBGiteaTheme import kotlin.math.PI import kotlin.math.abs @@ -361,13 +366,33 @@ fun TabPager(tabs: List, current: T, modifier: Modifier = Modifier, conte ) } } - HorizontalPager(pager, modifier.fillMaxSize(), userScrollEnabled = false, key = { it }) { page -> - content(tabs[page]) + // Every tab stays composed, so a glide never waits for a screen to be built. Only the tab + // being shown may answer Back: the others are handed a dispatcher nothing presses. + val backOwner = LocalOnBackPressedDispatcherOwner.current + val lifecycleOwner = LocalLifecycleOwner.current + val noBack = remember(lifecycleOwner) { + object : OnBackPressedDispatcherOwner { + override val lifecycle get() = lifecycleOwner.lifecycle + override val onBackPressedDispatcher = OnBackPressedDispatcher() + } + } + HorizontalPager( + pager, + modifier.fillMaxSize(), + beyondViewportPageCount = tabs.size - 1, + userScrollEnabled = false, + key = { it }, + ) { page -> + val owner = if (tabs[page] == current && backOwner != null) backOwner else noBack + CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides owner) { content(tabs[page]) } } } const val TabGlideStepMillis = 50 +/** How long a screen takes to push in over the one it was opened from, or to leave on Back. */ +const val ScreenPushMillis = 300 + const val SplashLongestWaitMillis = 1500L private val SplashGradient = listOf(Color(0xFF4DB0FF), Color(0xFF2F98F9), Color(0xFF0A6FD8)) diff --git a/docs/development-log.md b/docs/development-log.md index feab5b0..bd00389 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -1657,3 +1657,21 @@ This is the append-only implementation and handoff record shared by Codex and Cl question was designed in code in the app's style, not on the Claude Design canvas first. - Agents: chips and the exit dialog were written by implementer (sonnet); the rest by main (opus). - Checks and results: the five required checks pass. + +## 2026-10-12 (continued) — TASK-069: pushes, steadier repository tabs, tabs kept composed + +- Request, from the product owner's device: opening a repository from 활동 중인 저장소 flickered + and should push in from the right while it loads; after sliding between the repository tabs + something seemed to refresh or realign; tab moves should have no stutter at all. +- What changed: `MainActivity` opens and closes screens with a full-width push (300 ms) in place + of the 90 ms fade. `RepositoryListScreen` remembers what each tab last showed and keeps showing + it through Loading, so the account line, the organisation chips and the rows no longer vanish + and return on a tab change, refresh or page turn. `TabPager` keeps all four tabs composed + (`beyondViewportPageCount`), and gives the tabs not being shown a Back dispatcher nothing + presses, so a hidden tab cannot answer Back. +- Design decision: a remembered list may be a moment out of date (a favourite just starred); + the fresh one replaces it in place when it arrives. It is dropped on sign-out. +- Checks and results: the five required checks pass. +- Caution: what is installed for these looks is the debug build, which Compose runs noticeably + less smoothly than a minified release build; the published APK is the same kind (R8 is on hold + under TASK-016). Remaining stutter may be that and not the transitions. diff --git a/tasks/069-in-app-motion.md b/tasks/069-in-app-motion.md index 1d7bc93..e1bddbc 100644 --- a/tasks/069-in-app-motion.md +++ b/tasks/069-in-app-motion.md @@ -1,6 +1,6 @@ # TASK-069: In-app motion -Status: implemented; required checks pass; not yet seen on a device +Status: implemented; required checks pass; being adjusted from the product owner's look on a device Owner: Claude Reviewer: human product owner @@ -29,6 +29,11 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c 11. Asked by the product owner (2026-10-12): going from 홈 to 알림 should pass 저장소 and 작업 on the way, quickly, not jump straight there. The four tab screens now sit side by side in a pager that only a tab press moves. +12. After the product owner's first look on a device (2026-10-12): opening a screen pushes it in + from the right, the full width, as the one behind leaves to the left, and Back reverses it; + this replaces the short fade of item 5, which read as a flicker. The repository tabs keep + showing each tab's last list while it reloads, so nothing settles after the glide. All four + bottom tabs stay composed, so a glide does not wait for a screen to be built. ## Scope @@ -53,7 +58,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 eleven items above is in the code and the app builds. +- [x] Each of the twelve 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