TASK-069: tabs inside a screen glide like the bottom tabs

The repository list (all / favourites) and a repository's issues and pull
requests keep their header and tab row still while the list under them moves
the whole width. TabGlide in Motion.kt holds the movement.

A repository tab change reloads through Loading, which names no tab, so the
screen remembers the tab that was tapped and keeps the tab row up meanwhile.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-10-12 14:37:00 +09:00
parent 7368b27c65
commit 1f6f0dac8a
5 changed files with 243 additions and 121 deletions

View File

@ -34,6 +34,11 @@ import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
@ -63,6 +68,7 @@ import com.ubisam.ubgitea.ui.components.MonoChip
import com.ubisam.ubgitea.ui.components.ScreenTopBar
import com.ubisam.ubgitea.ui.components.SectionTitle
import com.ubisam.ubgitea.ui.components.StatusBadge
import com.ubisam.ubgitea.ui.components.TabGlide
import com.ubisam.ubgitea.ui.components.TeaPullToRefreshBox
import com.ubisam.ubgitea.ui.components.UBIcon
import com.ubisam.ubgitea.ui.components.UbiSamLogo
@ -95,6 +101,20 @@ fun RepositoryListScreen(
onViewSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
// 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) }
LaunchedEffect(state) {
if (state != RepositoryListUiState.Loading) requestedMode = null
}
val showAllRepositories = {
requestedMode = RepositoryListMode.ALL
onShowAllRepositories()
}
val showFavorites = {
requestedMode = RepositoryListMode.FAVORITES
onShowFavorites()
}
Column(modifier.fillMaxSize()) {
when (state) {
RepositoryListUiState.SignedOut -> SignedOutState(
@ -107,38 +127,69 @@ fun RepositoryListScreen(
stringResource(R.string.repositories_cancel_login),
onCancelSignIn,
)
RepositoryListUiState.Loading -> {
RepositoryListUiState.Loading,
is RepositoryListUiState.Empty,
is RepositoryListUiState.Content -> {
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
ListSkeleton(label = stringResource(R.string.repositories_loading))
}
is RepositoryListUiState.Empty -> {
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(bottom = 12.dp)) {
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
}
val mode = when (state) {
is RepositoryListUiState.Content -> state.mode
is RepositoryListUiState.Empty -> state.mode
else -> requestedMode
}
// FAVORITES-mode empty doesn't mean the account has nothing to show -- it means
// nothing is starred *yet*, while 전체 저장소 (the other tab, right above this
// message) is very likely non-empty. The only sensible primary action here is
// "go look at 전체 저장소 and star something", never 로그아웃.
if (state.mode == RepositoryListMode.FAVORITES) {
CenteredMessage(
stringResource(R.string.repositories_favorites_empty),
action = stringResource(R.string.repositories_favorites_empty_action),
onAction = onShowAllRepositories,
decoration = { EmptyCup() },
)
if (mode == null) {
ListSkeleton(label = stringResource(R.string.repositories_loading))
} else {
CenteredMessage(
stringResource(R.string.repositories_empty_account, state.accountName),
action = stringResource(R.string.repositories_logout),
onAction = onLogout,
)
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
modifier = Modifier.fillMaxWidth(),
) {
// 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()
) {
OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization)
}
}
}
TabGlide(mode to state, { it.first.ordinal }, Modifier.weight(1f).fillMaxWidth()) { (_, shown) ->
Column(Modifier.fillMaxSize()) {
when (shown) {
is RepositoryListUiState.Content -> RepositoryContent(
shown,
onRepositorySelected,
onNextPage,
onPreviousPage,
onRefreshRepositories,
onRetryLastPushedBranch,
showAllRepositories,
showFavorites,
onToggleFavorite,
)
// FAVORITES-mode empty doesn't mean the account has nothing to show -- it
// means nothing is starred *yet*, while 전체 저장소 (the other tab, right
// above this message) is very likely non-empty. The only sensible primary
// action here is "go look at 전체 저장소 and star something", never 로그아웃.
is RepositoryListUiState.Empty -> if (shown.mode == RepositoryListMode.FAVORITES) {
CenteredMessage(
stringResource(R.string.repositories_favorites_empty),
action = stringResource(R.string.repositories_favorites_empty_action),
onAction = showAllRepositories,
decoration = { EmptyCup() },
)
} else {
CenteredMessage(
stringResource(R.string.repositories_empty_account, shown.accountName),
action = stringResource(R.string.repositories_logout),
onAction = onLogout,
)
}
else -> ListSkeleton(label = stringResource(R.string.repositories_loading))
}
}
}
}
}
is RepositoryListUiState.Error -> {
@ -156,21 +207,6 @@ fun RepositoryListScreen(
onSecondaryAction = onLogout,
)
}
is RepositoryListUiState.Content -> {
BrandTopBar(state.accountName, instanceHost, onViewSettings, onLogout)
RepositoryContent(
state,
onRepositorySelected,
onNextPage,
onPreviousPage,
onRefreshRepositories,
onRetryLastPushedBranch,
onShowAllRepositories,
onShowFavorites,
onSelectOrganization,
onToggleFavorite,
)
}
is RepositoryListUiState.ReferencesLoading -> {
BackHandler(onBack = onBackToRepositories)
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
@ -225,22 +261,8 @@ private fun RepositoryContent(
onRetryLastPushedBranch: (RepositoryCoordinates) -> Unit,
onShowAllRepositories: () -> Unit,
onShowFavorites: () -> Unit,
onSelectOrganization: (String?) -> Unit,
onToggleFavorite: (RepositoryRowUiModel) -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
modifier = Modifier.fillMaxWidth(),
) {
// No title: the selected tab already says this is 저장소.
Column(Modifier.padding(bottom = 12.dp)) {
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
if (state.mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()) {
OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization)
}
}
}
state.cachedAtEpochMillis?.let { cachedAt ->
CautionBanner(stringResource(R.string.repositories_offline_cached_banner, formatInstant(cachedAt)))
}

View File

@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@ -69,6 +70,7 @@ import com.ubisam.ubgitea.ui.components.MonoChip
import com.ubisam.ubgitea.ui.components.ScreenTopBar
import com.ubisam.ubgitea.ui.components.SectionTitle
import com.ubisam.ubgitea.ui.components.StatusBadge
import com.ubisam.ubgitea.ui.components.TabGlide
import com.ubisam.ubgitea.ui.components.UBIcon
import com.ubisam.ubgitea.ui.formatRelative
@ -92,69 +94,29 @@ fun WorkItemsScreen(
Column(modifier.fillMaxSize()) {
when (state) {
WorkItemsUiState.Idle -> Unit
is WorkItemsUiState.Loading -> {
BackHandler(onBack = onBack)
ListHeader(state.target, onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
ListSkeleton()
}
is WorkItemsUiState.IssueList -> {
BackHandler(onBack = onBack)
ListHeader(state.target, onBack)
ModeTabs(WorkItemsTab.ISSUES, onShowIssues, onShowPullRequests)
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
itemsIndexed(state.items, key = { _, issue -> issue.number }) { index, issue ->
WorkItemRow(
icon = R.drawable.ic_ub_issue,
tone = issue.state.tone(),
number = issue.number,
title = issue.title,
statusLabel = stringResource(issue.state.labelResource),
author = issue.author.label(),
updatedAtEpochMillis = issue.updatedAtEpochMillis,
commentCount = issue.commentCount,
isFirst = index == 0,
isLast = index == state.items.lastIndex,
onSelected = { onIssueSelected(issue) },
)
}
}
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
}
is WorkItemsUiState.PullRequestList -> {
BackHandler(onBack = onBack)
ListHeader(state.target, onBack)
ModeTabs(WorkItemsTab.PULL_REQUESTS, onShowIssues, onShowPullRequests)
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
itemsIndexed(state.items, key = { _, pr -> pr.number }) { index, pr ->
WorkItemRow(
icon = if (pr.state == PullRequestState.MERGED) R.drawable.ic_ub_merge else R.drawable.ic_ub_pull_request,
tone = pr.state.tone(),
number = pr.number,
title = pr.title,
statusLabel = stringResource(pr.state.labelResource),
author = pr.author.label(),
updatedAtEpochMillis = pr.updatedAtEpochMillis,
commentCount = pr.commentCount,
isFirst = index == 0,
isLast = index == state.items.lastIndex,
onSelected = { onPullRequestSelected(pr) },
)
}
}
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
}
is WorkItemsUiState.Empty -> {
BackHandler(onBack = onBack)
ListHeader(state.target, onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
CenteredMessage(stringResource(R.string.workitems_empty), decoration = { EmptyCup() })
}
is WorkItemsUiState.Loading,
is WorkItemsUiState.IssueList,
is WorkItemsUiState.PullRequestList,
is WorkItemsUiState.Empty,
is WorkItemsUiState.ListError -> {
BackHandler(onBack = onBack)
ListHeader(state.target, onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
ErrorMessage(state.type, onRetryList, onAuthenticationExpired)
state.listTarget?.let { ListHeader(it, onBack) }
ModeTabs(state.listTab, onShowIssues, onShowPullRequests)
// The header and the tabs stay put; only what is under them changes sides.
TabGlide(state, { it.listTab.ordinal }, Modifier.weight(1f).fillMaxWidth()) { shown ->
Column(Modifier.fillMaxSize()) {
ListBody(
shown,
onIssueSelected,
onPullRequestSelected,
onNextPage,
onPreviousPage,
onGoToPage,
onRetryList,
onAuthenticationExpired,
)
}
}
}
is WorkItemsUiState.IssueDetailLoading -> {
BackHandler(onBack = onBackToList)
@ -185,6 +147,91 @@ fun WorkItemsScreen(
}
}
private val WorkItemsUiState.listTab: WorkItemsTab
get() = when (this) {
is WorkItemsUiState.Loading -> tab
is WorkItemsUiState.Empty -> tab
is WorkItemsUiState.ListError -> tab
is WorkItemsUiState.PullRequestList -> WorkItemsTab.PULL_REQUESTS
else -> WorkItemsTab.ISSUES
}
private val WorkItemsUiState.listTarget: WorkItemsTarget?
get() = when (this) {
is WorkItemsUiState.Loading -> target
is WorkItemsUiState.IssueList -> target
is WorkItemsUiState.PullRequestList -> target
is WorkItemsUiState.Empty -> target
is WorkItemsUiState.ListError -> target
else -> null
}
/** One tab's worth of the list: what [TabGlide] slides in and out under the tab row. */
@Composable
private fun ColumnScope.ListBody(
state: WorkItemsUiState,
onIssueSelected: (IssueSummary) -> Unit,
onPullRequestSelected: (PullRequestSummary) -> Unit,
onNextPage: () -> Unit,
onPreviousPage: () -> Unit,
onGoToPage: (Int) -> Unit,
onRetryList: () -> Unit,
onAuthenticationExpired: () -> Unit,
) {
when (state) {
is WorkItemsUiState.Loading -> {
ListSkeleton()
}
is WorkItemsUiState.IssueList -> {
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
itemsIndexed(state.items, key = { _, issue -> issue.number }) { index, issue ->
WorkItemRow(
icon = R.drawable.ic_ub_issue,
tone = issue.state.tone(),
number = issue.number,
title = issue.title,
statusLabel = stringResource(issue.state.labelResource),
author = issue.author.label(),
updatedAtEpochMillis = issue.updatedAtEpochMillis,
commentCount = issue.commentCount,
isFirst = index == 0,
isLast = index == state.items.lastIndex,
onSelected = { onIssueSelected(issue) },
)
}
}
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
}
is WorkItemsUiState.PullRequestList -> {
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
itemsIndexed(state.items, key = { _, pr -> pr.number }) { index, pr ->
WorkItemRow(
icon = if (pr.state == PullRequestState.MERGED) R.drawable.ic_ub_merge else R.drawable.ic_ub_pull_request,
tone = pr.state.tone(),
number = pr.number,
title = pr.title,
statusLabel = stringResource(pr.state.labelResource),
author = pr.author.label(),
updatedAtEpochMillis = pr.updatedAtEpochMillis,
commentCount = pr.commentCount,
isFirst = index == 0,
isLast = index == state.items.lastIndex,
onSelected = { onPullRequestSelected(pr) },
)
}
}
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
}
is WorkItemsUiState.Empty -> {
CenteredMessage(stringResource(R.string.workitems_empty), decoration = { EmptyCup() })
}
is WorkItemsUiState.ListError -> {
ErrorMessage(state.type, onRetryList, onAuthenticationExpired)
}
else -> Unit
}
}
@Composable
private fun ListHeader(target: WorkItemsTarget, onBack: () -> Unit) {
ScreenTopBar(title = target.repository.name, onBack = onBack, subtitle = target.repository.owner)

View File

@ -1,5 +1,6 @@
package com.ubisam.ubgitea.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
@ -10,6 +11,9 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -57,11 +61,12 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription
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 com.ubisam.ubgitea.ui.theme.UBGiteaTheme
import kotlinx.coroutines.delay
import kotlin.math.PI
import kotlin.math.sin
import kotlinx.coroutines.delay
// 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.
@ -276,6 +281,35 @@ fun StaggeredReveal(index: Int, active: Boolean, content: @Composable () -> Unit
const val StaggerStepMillis = 26L
/**
* What sits under a row of tabs glides sideways when the tab changes, the way the bottom tabs do:
* the new tab's content comes in from the side its tab is on while the old one leaves the other
* way. [state] may change freely within one tab (loading to list, one page to the next) without
* replaying it; the leaving side keeps showing the last state it had.
*/
@Composable
fun <S> TabGlide(
state: S,
tabIndex: (S) -> Int,
modifier: Modifier = Modifier,
content: @Composable (S) -> Unit,
) {
AnimatedContent(
targetState = state,
modifier = modifier,
transitionSpec = {
val direction = (tabIndex(targetState) - tabIndex(initialState)).coerceIn(-1, 1)
val glide = tween<IntOffset>(TabGlideMillis, easing = FastOutSlowInEasing)
slideInHorizontally(glide) { it * direction }
.togetherWith(slideOutHorizontally(glide) { -it * direction })
},
contentKey = tabIndex,
label = "tab",
) { shown -> content(shown) }
}
const val TabGlideMillis = 320
private val SplashGradient = listOf(Color(0xFF4DB0FF), Color(0xFF2F98F9), Color(0xFF0A6FD8))
/**

View File

@ -1561,3 +1561,19 @@ This is the append-only implementation and handoff record shared by Codex and Cl
the short slide.
- Checks and results: the five required checks pass. Not seen on a device; not in the published
0.2.2.
## 2026-10-12 (continued) — TASK-069: tabs inside a screen glide too
- Request: the product owner wanted the other tab switches to glide like the bottom tabs.
- What changed: `TabGlide` in `Motion.kt` holds the movement. In the repository list (전체 저장소 /
즐겨찾기) and in a repository's issues and pull requests, the header and the tab row stay where
they are and the list under them moves the whole width, towards the side the tab is on. Paging
or loading inside one tab does not replay it.
- Design decision: a repository tab change reloads through `Loading`, which names no tab, so the
screen remembers the tab that was tapped and keeps the tab row up during that load. Before, the
tab row disappeared until the list came back. A load not started from a tab is as before. The
view model and its states are untouched.
- Left alone: the filter chips (home, 내 작업, 알림, organisations) filter one list rather than
switch tabs. The branch screen's tabs were already a pager.
- Checks and results: the five required checks pass. Not seen on a device; not in the published
0.2.2.

View File

@ -19,6 +19,9 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
8. Added while this was in progress (product owner, 2026-10-12): switching bottom tabs slides the
whole screen sideways, the old tab gliding out as the new one glides in, and the tab's icon
springs.
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.
## Scope
@ -43,7 +46,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 eight items above is in the code and the app builds.
- [x] Each of the nine 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