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 2bf4d2d..24b42a0 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,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(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))) } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt index 7a110d2..a53c616 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt @@ -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) 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 8f55813..809ba57 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,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 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(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)) /** diff --git a/docs/development-log.md b/docs/development-log.md index 13c7797..9e6f778 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -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. diff --git a/tasks/069-in-app-motion.md b/tasks/069-in-app-motion.md index 7be1472..d9be636 100644 --- a/tasks/069-in-app-motion.md +++ b/tasks/069-in-app-motion.md @@ -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