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:
parent
7368b27c65
commit
1f6f0dac8a
@ -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)))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
/**
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user