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 <noreply@anthropic.com>
This commit is contained in:
parent
d9baf04a1d
commit
db8fc0574e
@ -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<IntOffset>(ScreenPushMillis, easing = FastOutSlowInEasing)
|
||||
slideInHorizontally(push) { it * direction }
|
||||
.togetherWith(slideOutHorizontally(push) { -it * direction })
|
||||
.using(NoResize)
|
||||
}
|
||||
},
|
||||
|
||||
@ -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<RepositoryListMode?>(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<RepositoryListMode, RepositoryListUiState>() }
|
||||
var seenMode by remember { mutableStateOf<RepositoryListMode?>(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(
|
||||
|
||||
@ -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 <T> TabPager(tabs: List<T>, 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))
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user