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.activity.viewModels
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@ -46,6 +48,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||||
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
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.feature.workitems.WorkItemsViewModel
|
||||||
import com.ubisam.ubgitea.ui.components.ExitConfirmDialog
|
import com.ubisam.ubgitea.ui.components.ExitConfirmDialog
|
||||||
import com.ubisam.ubgitea.ui.components.NoResize
|
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.SplashOverlay
|
||||||
import com.ubisam.ubgitea.ui.components.TabPager
|
import com.ubisam.ubgitea.ui.components.TabPager
|
||||||
import com.ubisam.ubgitea.ui.navigation.Route
|
import com.ubisam.ubgitea.ui.navigation.Route
|
||||||
@ -439,7 +443,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
onViewSettings = { push(Route.Settings) },
|
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
|
// 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.
|
// not replay it. The four tabs are one kind: between them the pager below glides.
|
||||||
val stackDepth = routeStack.size
|
val stackDepth = routeStack.size
|
||||||
@ -453,15 +458,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories,
|
targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
transitionSpec = {
|
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
|
// The session has just been found: the first screen takes the
|
||||||
// waiting one's place where it stands. Nothing was navigated, so
|
// waiting one's place where it stands. Nothing was navigated, so
|
||||||
// nothing slides.
|
// nothing slides.
|
||||||
fadeIn(tween(200)).togetherWith(fadeOut(tween(90))).using(NoResize)
|
fadeIn(tween(200)).togetherWith(fadeOut(tween(90))).using(NoResize)
|
||||||
} else {
|
} else {
|
||||||
val direction = (stackDepth - shownDepth[0]).coerceIn(-1, 1)
|
val push = tween<IntOffset>(ScreenPushMillis, easing = FastOutSlowInEasing)
|
||||||
(fadeIn(tween(200, delayMillis = 50)) + slideInHorizontally(tween(260)) { it / 10 * direction })
|
slideInHorizontally(push) { it * direction }
|
||||||
.togetherWith(fadeOut(tween(90)))
|
.togetherWith(slideOutHorizontally(push) { -it * direction })
|
||||||
.using(NoResize)
|
.using(NoResize)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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
|
// 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.
|
// 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) }
|
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) {
|
LaunchedEffect(state) {
|
||||||
if (state != RepositoryListUiState.Loading) requestedMode = null
|
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 = {
|
val showAllRepositories = {
|
||||||
requestedMode = RepositoryListMode.ALL
|
requestedMode = RepositoryListMode.ALL
|
||||||
@ -130,11 +152,13 @@ fun RepositoryListScreen(
|
|||||||
RepositoryListUiState.Loading,
|
RepositoryListUiState.Loading,
|
||||||
is RepositoryListUiState.Empty,
|
is RepositoryListUiState.Empty,
|
||||||
is RepositoryListUiState.Content -> {
|
is RepositoryListUiState.Content -> {
|
||||||
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
val loadingMode = requestedMode ?: seenMode
|
||||||
val mode = when (state) {
|
val shownState = if (state == RepositoryListUiState.Loading) loadingMode?.let { seen[it] } ?: state else state
|
||||||
is RepositoryListUiState.Content -> state.mode
|
BrandTopBar(shownState.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
||||||
is RepositoryListUiState.Empty -> state.mode
|
val mode = when (shownState) {
|
||||||
else -> requestedMode
|
is RepositoryListUiState.Content -> shownState.mode
|
||||||
|
is RepositoryListUiState.Empty -> shownState.mode
|
||||||
|
else -> loadingMode
|
||||||
}
|
}
|
||||||
if (mode == null) {
|
if (mode == null) {
|
||||||
ListSkeleton(label = stringResource(R.string.repositories_loading))
|
ListSkeleton(label = stringResource(R.string.repositories_loading))
|
||||||
@ -147,14 +171,14 @@ fun RepositoryListScreen(
|
|||||||
// No title: the selected tab already says this is 저장소.
|
// No title: the selected tab already says this is 저장소.
|
||||||
Column(Modifier.padding(bottom = 12.dp)) {
|
Column(Modifier.padding(bottom = 12.dp)) {
|
||||||
ModeTabs(mode, showAllRepositories, showFavorites)
|
ModeTabs(mode, showAllRepositories, showFavorites)
|
||||||
if (state is RepositoryListUiState.Content &&
|
if (shownState is RepositoryListUiState.Content &&
|
||||||
mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()
|
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()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
when (shown) {
|
when (shown) {
|
||||||
is RepositoryListUiState.Content -> RepositoryContent(
|
is RepositoryListUiState.Content -> RepositoryContent(
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
package com.ubisam.ubgitea.ui.components
|
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.AnimatedContent
|
||||||
import androidx.compose.animation.SizeTransform
|
import androidx.compose.animation.SizeTransform
|
||||||
import androidx.compose.animation.core.Animatable
|
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.PullToRefreshState
|
||||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
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.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
import kotlin.math.abs
|
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 ->
|
// Every tab stays composed, so a glide never waits for a screen to be built. Only the tab
|
||||||
content(tabs[page])
|
// 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
|
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
|
const val SplashLongestWaitMillis = 1500L
|
||||||
|
|
||||||
private val SplashGradient = listOf(Color(0xFF4DB0FF), Color(0xFF2F98F9), Color(0xFF0A6FD8))
|
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.
|
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).
|
- Agents: chips and the exit dialog were written by implementer (sonnet); the rest by main (opus).
|
||||||
- Checks and results: the five required checks pass.
|
- 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
|
# 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
|
Owner: Claude
|
||||||
Reviewer: human product owner
|
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 작업
|
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
|
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.
|
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
|
## 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
|
## 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
|
- [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.
|
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
|
- [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