diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index 54dad59..5cfbd26 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -272,20 +272,28 @@ class MainActivity : ComponentActivity() { homeViewModel.close() workViewModel.close() } - fun signOut() { - closeFeatures() - selectTab(AppTab.HOME) - viewModel.logout() + // Held, not made again on each pass: the screens are given these, and a screen given + // the same callbacks as before is left alone when something else here changes. + val signOut: () -> Unit = remember { + { + closeFeatures() + selectTab(AppTab.HOME) + viewModel.logout() + } } - fun leaveBranchDetail() { - codeBrowserViewModel.close() - historyViewModel.close() - pop() + val leaveBranchDetail: () -> Unit = remember { + { + codeBrowserViewModel.close() + historyViewModel.close() + pop() + } } var confirmExit by remember { mutableStateOf(false) } val instanceHost = UBGiteaOAuthDefaults.config.baseUri.host.orEmpty() - val codeViewerPreferences = CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp) + val codeViewerPreferences = remember(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp) { + CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp) + } val showNavigation = sessionReady && when (currentRoute) { is Route.BranchDetail -> !isReadingFullScreen(codeBrowserState, historyState) Route.Settings, Route.Compare -> false @@ -322,23 +330,28 @@ class MainActivity : ComponentActivity() { SavedCodeTab(route.owner, route.name, it.target.kind.name, it.target.ref.value, it.path.value) } } - val workbench = if (sessionReady) { - CodeWorkbench( - tabs = workbenchState.tabs, - pending = workbenchState.pending, - fetching = workbenchState.fetching, - onToggleTab = workbenchViewModel::toggleTab, - onPin = workbenchViewModel::pin, - onCompareWith = workbenchViewModel::compareWith, - onClearPin = workbenchViewModel::clearPin, - onUseCommitFile = { commitId, shortId, path, side -> - (currentRoute as? Route.BranchDetail)?.let { route -> - workbenchViewModel.useCommitFile(RepositoryCoordinates.of(route.owner, route.name), commitId, shortId, path, side) - } - }, - ) - } else { - null + // Made again only when what it holds changes. Every screen is composed under it, and + // a new one, even one that says the same, has every screen on every tab drawn again: + // that was a stall of several frames at the start of each tab change and each push. + val workbench = remember(sessionReady, workbenchState.tabs, workbenchState.pending, workbenchState.fetching) { + if (sessionReady) { + CodeWorkbench( + tabs = workbenchState.tabs, + pending = workbenchState.pending, + fetching = workbenchState.fetching, + onToggleTab = workbenchViewModel::toggleTab, + onPin = workbenchViewModel::pin, + onCompareWith = workbenchViewModel::compareWith, + onClearPin = workbenchViewModel::clearPin, + onUseCommitFile = { commitId, shortId, path, side -> + (routeStack.last() as? Route.BranchDetail)?.let { route -> + workbenchViewModel.useCommitFile(RepositoryCoordinates.of(route.owner, route.name), commitId, shortId, path, side) + } + }, + ) + } else { + null + } } val showStrip = sessionReady && currentRoute != Route.Settings && currentRoute != Route.Compare && (workbenchState.tabs.isNotEmpty() || workbenchState.pending != null || workbenchState.fetching) @@ -406,15 +419,18 @@ class MainActivity : ComponentActivity() { BackHandler(enabled = sessionReady && currentRoute.isTab) { if (currentRoute == Route.Home) confirmExit = true else selectTab(AppTab.HOME) } - val repositoryList: @Composable () -> Unit = { + // The screens are built once and held, and read what changes from state. A + // tab press, which runs everything around them again, then leaves the four tab + // screens exactly as they were instead of drawing each of them again. + val repositoryList: @Composable () -> Unit = remember { { RepositoryListScreen( state = state, instanceHost = instanceHost, - onSignIn = viewModel::signIn, - onCancelSignIn = viewModel::cancelSignIn, - onRetry = viewModel::retry, - onLogout = ::signOut, - onRepositorySelected = viewModel::selectRepository, + onSignIn = remember { viewModel::signIn }, + onCancelSignIn = remember { viewModel::cancelSignIn }, + onRetry = remember { viewModel::retry }, + onLogout = signOut, + onRepositorySelected = remember { viewModel::selectRepository }, onReferenceSelected = { repository, name, kind -> viewModel.selectReference(name, kind) val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG @@ -427,22 +443,22 @@ class MainActivity : ComponentActivity() { ), ) }, - onReferencesRefresh = viewModel::refreshReferences, - onBackToRepositories = viewModel::backToRepositories, - onNextPage = viewModel::nextPage, - onPreviousPage = viewModel::previousPage, - onRefreshRepositories = viewModel::refreshRepositories, - onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch, - onToggleFavorite = viewModel::toggleFavorite, - onShowAllRepositories = viewModel::showAllRepositories, - onShowFavorites = viewModel::showFavorites, + onReferencesRefresh = remember { viewModel::refreshReferences }, + onBackToRepositories = remember { viewModel::backToRepositories }, + onNextPage = remember { viewModel::nextPage }, + onPreviousPage = remember { viewModel::previousPage }, + onRefreshRepositories = remember { viewModel::refreshRepositories }, + onRetryLastPushedBranch = remember { viewModel::retryRowLastPushedBranch }, + onToggleFavorite = remember { viewModel::toggleFavorite }, + onShowAllRepositories = remember { viewModel::showAllRepositories }, + onShowFavorites = remember { viewModel::showFavorites }, onViewWorkItems = { repository -> push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name)) }, - onSelectOrganization = viewModel::selectOrganization, + onSelectOrganization = remember { viewModel::selectOrganization }, onViewSettings = { push(Route.Settings) }, ) - } + } } // 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 @@ -474,7 +490,7 @@ class MainActivity : ComponentActivity() { contentKey = { if (it.isTab) AppTab::class else it::class }, label = "route", ) { shown -> - val screen: @Composable (Route) -> Unit = { route -> + val screen: @Composable (Route) -> Unit = remember(isWide, codeViewerPreferences) { { route -> Box(Modifier.fillMaxSize()) { when (route) { Route.Home -> HomeScreen( @@ -502,52 +518,52 @@ class MainActivity : ComponentActivity() { ) } }, - onSelectSource = homeViewModel::selectSource, - onSelectFilter = homeViewModel::selectFilter, - onSetTodayOnly = homeViewModel::setTodayOnly, - onShowToday = homeViewModel::showToday, - onLoadMore = homeViewModel::loadMore, - onRefresh = homeViewModel::refresh, + onSelectSource = remember { homeViewModel::selectSource }, + onSelectFilter = remember { homeViewModel::selectFilter }, + onSetTodayOnly = remember { homeViewModel::setTodayOnly }, + onShowToday = remember { homeViewModel::showToday }, + onLoadMore = remember { homeViewModel::loadMore }, + onRefresh = remember { homeViewModel::refresh }, onViewSettings = { push(Route.Settings) }, - onLogout = ::signOut, - onAuthenticationExpired = ::signOut, + onLogout = signOut, + onAuthenticationExpired = signOut, ) Route.Work -> WorkScreen( state = workState, accountName = accountName, instanceHost = instanceHost, - onSelectScope = workViewModel::selectScope, - onSelectState = workViewModel::selectState, - onSelectType = workViewModel::selectType, + onSelectScope = remember { workViewModel::selectScope }, + onSelectState = remember { workViewModel::selectState }, + onSelectType = remember { workViewModel::selectType }, onItemSelected = { hit -> push(Route.WorkItems(hit.repository.owner, hit.repository.name, hit.number, hit.isPullRequest)) }, - onNextPage = workViewModel::nextPage, - onPreviousPage = workViewModel::previousPage, - onRefresh = workViewModel::refresh, + onNextPage = remember { workViewModel::nextPage }, + onPreviousPage = remember { workViewModel::previousPage }, + onRefresh = remember { workViewModel::refresh }, onViewSettings = { push(Route.Settings) }, - onLogout = ::signOut, - onAuthenticationExpired = ::signOut, + onLogout = signOut, + onAuthenticationExpired = signOut, ) Route.Settings -> SettingsScreen( state = settingsState, - onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled, - onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled, + onSetReactiveAuthRetryEnabled = remember { settingsViewModel::setReactiveAuthRetryEnabled }, + onSetFavoritePushNotificationsEnabled = remember { settingsViewModel::setFavoritePushNotificationsEnabled }, accountName = accountName, instanceHost = instanceHost, - onSetCodeWordWrapEnabled = settingsViewModel::setCodeWordWrapEnabled, - onSetCodeFontSizeSp = settingsViewModel::setCodeFontSizeSp, - onSetThemeMode = settingsViewModel::setThemeMode, - onLogout = ::signOut, + onSetCodeWordWrapEnabled = remember { settingsViewModel::setCodeWordWrapEnabled }, + onSetCodeFontSizeSp = remember { settingsViewModel::setCodeFontSizeSp }, + onSetThemeMode = remember { settingsViewModel::setThemeMode }, + onLogout = signOut, onBack = { pop() }, update = updateState, - onCheckUpdate = updateViewModel::checkNow, - onInstallUpdate = ::startUpdate, + onCheckUpdate = remember { updateViewModel::checkNow }, + onInstallUpdate = remember { ::startUpdate }, ) Route.Notifications -> NotificationsScreen( state = notificationsState, - onShowUnreadOnly = notificationsViewModel::showUnreadOnly, - onShowAll = notificationsViewModel::showAll, + onShowUnreadOnly = remember { notificationsViewModel::showUnreadOnly }, + onShowAll = remember { notificationsViewModel::showAll }, onNotificationSelected = { notification -> push( Route.WorkItems( @@ -558,15 +574,15 @@ class MainActivity : ComponentActivity() { ), ) }, - onNextPage = notificationsViewModel::nextPage, - onPreviousPage = notificationsViewModel::previousPage, - onRetry = notificationsViewModel::retry, + onNextPage = remember { notificationsViewModel::nextPage }, + onPreviousPage = remember { notificationsViewModel::previousPage }, + onRetry = remember { notificationsViewModel::retry }, onBack = { selectTab(AppTab.HOME) }, - onAuthenticationExpired = ::signOut, + onAuthenticationExpired = signOut, accountName = accountName, instanceHost = instanceHost, onViewSettings = { push(Route.Settings) }, - onLogout = ::signOut, + onLogout = signOut, ) is Route.WorkItems -> { val leave = { @@ -575,21 +591,21 @@ class MainActivity : ComponentActivity() { } WorkItemsScreen( state = workItemsState, - onShowIssues = workItemsViewModel::showIssues, - onShowPullRequests = workItemsViewModel::showPullRequests, - onIssueSelected = workItemsViewModel::openIssue, - onPullRequestSelected = workItemsViewModel::openPullRequest, - onNextPage = workItemsViewModel::nextPage, - onPreviousPage = workItemsViewModel::previousPage, - onGoToPage = workItemsViewModel::goToPage, - onRetryList = workItemsViewModel::retryList, - onRetryDetail = workItemsViewModel::retryDetail, + onShowIssues = remember { workItemsViewModel::showIssues }, + onShowPullRequests = remember { workItemsViewModel::showPullRequests }, + onIssueSelected = remember { workItemsViewModel::openIssue }, + onPullRequestSelected = remember { workItemsViewModel::openPullRequest }, + onNextPage = remember { workItemsViewModel::nextPage }, + onPreviousPage = remember { workItemsViewModel::previousPage }, + onGoToPage = remember { workItemsViewModel::goToPage }, + onRetryList = remember { workItemsViewModel::retryList }, + onRetryDetail = remember { workItemsViewModel::retryDetail }, // An item opened directly came from a list elsewhere (a search, the // timeline, a notification), so back returns there, not to this // repository's own list. - onBackToList = if (route.number != null) leave else workItemsViewModel::backToList, + onBackToList = if (route.number != null) leave else remember { workItemsViewModel::backToList }, onBack = leave, - onAuthenticationExpired = ::signOut, + onAuthenticationExpired = signOut, ) } is Route.BranchDetail -> BranchDetailScreen( @@ -598,46 +614,47 @@ class MainActivity : ComponentActivity() { ref = route.ref, codeBrowserState = codeBrowserState, codeViewerPreferences = codeViewerPreferences, - onEntrySelected = codeBrowserViewModel::openEntry, - onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb, - onRetryCodeBrowser = codeBrowserViewModel::retry, + onEntrySelected = remember { codeBrowserViewModel::openEntry }, + onBreadcrumbSelected = remember { codeBrowserViewModel::openBreadcrumb }, + onRetryCodeBrowser = remember { codeBrowserViewModel::retry }, onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) leaveBranchDetail() }, - onNavigateImage = codeBrowserViewModel::navigateImage, - onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled, - onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp, - onOpenSibling = codeBrowserViewModel::openSibling, - onToggleFolder = codeBrowserViewModel::toggleFolder, - onLoadCounterpart = codeBrowserViewModel::loadCounterpart, + onNavigateImage = remember { codeBrowserViewModel::navigateImage }, + onSetCodeWordWrap = remember { settingsViewModel::setCodeWordWrapEnabled }, + onSetCodeFontSize = remember { settingsViewModel::setCodeFontSizeSp }, + onOpenSibling = remember { codeBrowserViewModel::openSibling }, + onToggleFolder = remember { codeBrowserViewModel::toggleFolder }, + onLoadCounterpart = remember { codeBrowserViewModel::loadCounterpart }, historyState = historyState, - onCommitSelected = historyViewModel::openCommit, - onHistoryNextPage = historyViewModel::nextPage, - onHistoryPreviousPage = historyViewModel::previousPage, - onRetryHistoryList = historyViewModel::retryList, - onRetryHistoryDetail = historyViewModel::retryDetail, - onRetryPatch = historyViewModel::retryPatch, - onFileSelected = historyViewModel::openFileDiff, - onShowAdjacentFile = historyViewModel::openAdjacentFileDiff, - onClearFileSelection = historyViewModel::clearFileSelection, - onHistoryBackToList = historyViewModel::backToList, - onHistoryBack = ::leaveBranchDetail, + onCommitSelected = remember { historyViewModel::openCommit }, + onHistoryNextPage = remember { historyViewModel::nextPage }, + onHistoryPreviousPage = remember { historyViewModel::previousPage }, + onRetryHistoryList = remember { historyViewModel::retryList }, + onRetryHistoryDetail = remember { historyViewModel::retryDetail }, + onRetryPatch = remember { historyViewModel::retryPatch }, + onFileSelected = remember { historyViewModel::openFileDiff }, + onShowAdjacentFile = remember { historyViewModel::openAdjacentFileDiff }, + onClearFileSelection = remember { historyViewModel::clearFileSelection }, + onHistoryBackToList = remember { historyViewModel::backToList }, + onHistoryBack = leaveBranchDetail, onViewWorkItems = { push(Route.WorkItems(route.owner, route.name)) }, - onClose = ::leaveBranchDetail, - onAuthenticationExpired = ::signOut, + onClose = leaveBranchDetail, + onAuthenticationExpired = signOut, ) Route.Repositories -> repositoryList() Route.Compare -> CompareScreen( state = workbenchState.compare, fontSizeSp = settingsState.codeFontSizeSp, - onSwap = workbenchViewModel::swapSides, - onClose = workbenchViewModel::closeCompare, + onSwap = remember { workbenchViewModel::swapSides }, + onClose = remember { workbenchViewModel::closeCompare }, ) } } - } + } } + val tabScreen: @Composable (AppTab) -> Unit = remember(screen) { { screen(it.route) } } // Tabs sit side by side: going from one to another passes the ones between. val shownTab = shown.tab if (shownTab != null && sessionReady) { - TabPager(AppTab.entries, shownTab) { screen(it.route) } + TabPager(AppTab.entries, shownTab, content = tabScreen) } else { screen(shown) } 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 6173dff..7309e19 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 @@ -69,7 +69,9 @@ import com.ubisam.ubgitea.ui.components.ScreenTopBar import com.ubisam.ubgitea.ui.components.ScrollTopLazyColumn 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.TabGlideMillis +import com.ubisam.ubgitea.ui.components.TabPager +import kotlinx.coroutines.delay import com.ubisam.ubgitea.ui.components.TeaPullToRefreshBox import com.ubisam.ubgitea.ui.components.UBIcon import com.ubisam.ubgitea.ui.components.UbiSamLogo @@ -110,6 +112,13 @@ fun RepositoryListScreen( // the new list replaces the old one in place and nothing appears to settle afterwards. val seen = remember { mutableStateMapOf() } var seenMode by remember { mutableStateOf(null) } + // A repository's branches open beside the list, not in place of it. The list stays built + // underneath and the branches stay built on the way out, so going in and coming back are + // both a slide, and neither starts by building a screen of rows. + var lastReferences by remember { mutableStateOf(null) } + val inReferences = state is RepositoryListUiState.ReferencesLoading || + state is RepositoryListUiState.ReferencesContent || + state is RepositoryListUiState.ReferencesError LaunchedEffect(state) { if (state != RepositoryListUiState.Loading) requestedMode = null when (state) { @@ -125,10 +134,24 @@ fun RepositoryListScreen( RepositoryListUiState.SignedOut -> { seen.clear() seenMode = null + lastReferences = null } + is RepositoryListUiState.ReferencesLoading, + is RepositoryListUiState.ReferencesContent, + is RepositoryListUiState.ReferencesError -> lastReferences = state else -> Unit } } + // Branches that arrive while their screen is still sliding in wait for it to stop: rows built + // in the middle of the slide made it stall. + var slidingIn by remember { mutableStateOf(false) } + LaunchedEffect(inReferences) { + if (inReferences) { + slidingIn = true + delay(TabGlideMillis.toLong()) + slidingIn = false + } + } val showAllRepositories = { requestedMode = RepositoryListMode.ALL onShowAllRepositories() @@ -149,73 +172,6 @@ fun RepositoryListScreen( stringResource(R.string.repositories_cancel_login), onCancelSignIn, ) - RepositoryListUiState.Loading, - is RepositoryListUiState.Empty, - is RepositoryListUiState.Content -> { - 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)) - } else { - 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 (shownState is RepositoryListUiState.Content && - mode == RepositoryListMode.ALL && shownState.organizations.isNotEmpty() - ) { - OrganizationFilterRow(shownState.organization, shownState.organizations, onSelectOrganization) - } - } - } - TabGlide(mode to shownState, { 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 -> { BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout) val needsLogin = state.type == RepositoryListError.AUTHENTICATION_EXPIRED || @@ -231,30 +187,123 @@ fun RepositoryListScreen( onSecondaryAction = onLogout, ) } - is RepositoryListUiState.ReferencesLoading -> { - BackHandler(onBack = onBackToRepositories) - ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner) - CenteredLoading(stringResource(R.string.repositories_references_loading)) - } - is RepositoryListUiState.ReferencesContent -> { - BackHandler(onBack = onBackToRepositories) - ReferencesContent(state, onReferenceSelected, onReferencesRefresh, onViewWorkItems, onBackToRepositories) - } - is RepositoryListUiState.ReferencesError -> { - BackHandler(onBack = onBackToRepositories) - ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner) - CenteredMessage( - stringResource(state.type.messageResource), - action = stringResource(R.string.repositories_retry), - onAction = onRetry, - secondaryAction = stringResource(R.string.repositories_back), - onSecondaryAction = onBackToRepositories, - ) + RepositoryListUiState.Loading, + is RepositoryListUiState.Empty, + is RepositoryListUiState.Content, + is RepositoryListUiState.ReferencesLoading, + is RepositoryListUiState.ReferencesContent, + is RepositoryListUiState.ReferencesError -> TabPager( + RepositoryDepth.entries, + if (inReferences) RepositoryDepth.REFERENCES else RepositoryDepth.LIST, + ) { depth -> + Column(Modifier.fillMaxSize()) { + if (depth == RepositoryDepth.LIST) { + val listState = if (inReferences) seenMode?.let { seen[it] } ?: RepositoryListUiState.Loading else state + val loadingMode = requestedMode ?: seenMode + val shownState = if (listState == RepositoryListUiState.Loading) loadingMode?.let { seen[it] } ?: listState else listState + 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)) + } else { + 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 (shownState is RepositoryListUiState.Content && + mode == RepositoryListMode.ALL && shownState.organizations.isNotEmpty() + ) { + OrganizationFilterRow(shownState.organization, shownState.organizations, onSelectOrganization) + } + } + } + // Both lists stay composed side by side, so changing tab only slides: building + // a list of rows in the frame the slide starts on is what made it stall. + TabPager(RepositoryListMode.entries, mode, Modifier.weight(1f).fillMaxWidth()) { page -> + val shown = if (page == mode) shownState else seen[page] ?: RepositoryListUiState.Loading + 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)) + } + } + } + } + } else { + val arrived = if (inReferences) state else lastReferences + val shown = if (slidingIn && arrived is RepositoryListUiState.ReferencesContent) { + RepositoryListUiState.ReferencesLoading(arrived.accountName, arrived.repository) + } else { + arrived + } + when (shown) { + is RepositoryListUiState.ReferencesLoading -> { + BackHandler(onBack = onBackToRepositories) + ScreenTopBar(title = shown.repository.name, onBack = onBackToRepositories, subtitle = shown.repository.owner) + CenteredLoading(stringResource(R.string.repositories_references_loading)) + } + is RepositoryListUiState.ReferencesContent -> { + BackHandler(onBack = onBackToRepositories) + ReferencesContent(shown, onReferenceSelected, onReferencesRefresh, onViewWorkItems, onBackToRepositories) + } + is RepositoryListUiState.ReferencesError -> { + BackHandler(onBack = onBackToRepositories) + ScreenTopBar(title = shown.repository.name, onBack = onBackToRepositories, subtitle = shown.repository.owner) + CenteredMessage( + stringResource(shown.type.messageResource), + action = stringResource(R.string.repositories_retry), + onAction = onRetry, + secondaryAction = stringResource(R.string.repositories_back), + onSecondaryAction = onBackToRepositories, + ) + } + else -> Unit + } + } + } } } } } +private enum class RepositoryDepth { LIST, REFERENCES } + @Composable private fun SignedOutState(message: String, action: String, onAction: () -> Unit) { Column( 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 947f5a7..8eedb59 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 @@ -6,6 +6,7 @@ import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Easing import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -45,6 +46,7 @@ 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.compositionLocalOf import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -123,13 +125,31 @@ private fun DrawScope.drawTeaCup(color: Color, steam: Float, steamAlpha: Float = } } +/** + * Whether what is composed here can be seen. A tab kept composed beside the one shown is not, and + * nothing in it should keep the display redrawing: looping motion rests while this is false. + */ +val LocalOnScreen = compositionLocalOf { true } + +/** A value going round from [from] to [to] for as long as it can be seen; [from] when it cannot. */ @Composable -private fun rememberSteam(): () -> Float { - val transition = rememberInfiniteTransition(label = "steam") - val steam by transition.animateFloat(0f, 1f, infiniteRepeatable(tween(1600, easing = LinearEasing)), label = "steam") - return { steam } +private fun rememberLoop( + from: Float, + to: Float, + millis: Int, + easing: Easing = LinearEasing, + repeatMode: RepeatMode = RepeatMode.Restart, + label: String, +): () -> Float { + if (!LocalOnScreen.current) return { from } + val transition = rememberInfiniteTransition(label = label) + val value by transition.animateFloat(from, to, infiniteRepeatable(tween(millis, easing = easing), repeatMode), label = label) + return { value } } +@Composable +private fun rememberSteam(): () -> Float = rememberLoop(0f, 1f, 1600, label = "steam") + /** The tea cup with rising steam: the app's "working on it" mark. */ /** * Holds [content] back for [LoaderDelayMillis] and then fades it in. A wait shorter than that @@ -159,8 +179,7 @@ fun TeaCupLoader(modifier: Modifier = Modifier, size: Dp = 48.dp, color: Color = */ @Composable fun ListSkeleton(modifier: Modifier = Modifier, rows: Int = 6, label: String? = null) { - val transition = rememberInfiniteTransition(label = "shimmer") - val sweep by transition.animateFloat(-1f, 2f, infiniteRepeatable(tween(1300, easing = LinearEasing)), label = "shimmer") + val sweep = rememberLoop(-1f, 2f, 1300, label = "shimmer") val base = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f) val light = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.16f) Column( @@ -176,7 +195,7 @@ fun ListSkeleton(modifier: Modifier = Modifier, rows: Int = 6, label: String? = horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - val shimmer = Modifier.drawShimmer(base, light) { sweep } + val shimmer = Modifier.drawShimmer(base, light, sweep) Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).then(shimmer)) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { // Rows differ in length so the block does not read as a table. @@ -205,13 +224,7 @@ private fun Modifier.drawShimmer(base: Color, light: Color, sweep: () -> Float): /** The cup, bobbing a little: sits above the message on a screen that has nothing to list. */ @Composable fun EmptyCup(modifier: Modifier = Modifier) { - val transition = rememberInfiniteTransition(label = "empty") - val bob by transition.animateFloat( - -1f, - 1f, - infiniteRepeatable(tween(1800, easing = FastOutSlowInEasing), RepeatMode.Reverse), - label = "bob", - ) + val bob = rememberLoop(-1f, 1f, 1800, FastOutSlowInEasing, RepeatMode.Reverse, label = "bob") val steam = rememberSteam() val color = UBGiteaTheme.colors.brand.copy(alpha = 0.55f) Canvas( @@ -219,8 +232,8 @@ fun EmptyCup(modifier: Modifier = Modifier) { .padding(bottom = 20.dp) .size(56.dp, 67.dp) .graphicsLayer { - translationY = bob * 3.dp.toPx() - rotationZ = bob * 2f + translationY = bob() * 3.dp.toPx() + rotationZ = bob() * 2f }, ) { drawTeaCup(color, steam()) } } @@ -376,6 +389,11 @@ fun TabPager(tabs: List, current: T, modifier: Modifier = Modifier, conte override val onBackPressedDispatcher = OnBackPressedDispatcher() } } + // What a page reads about the tab shown, it reads from state: a tab press then recomposes the + // two small scopes below and not the screens inside them. + val shown = rememberUpdatedState(current) + // A pager inside a tab: its pages are on screen only while that tab is. + val pagerOnScreen = LocalOnScreen.current HorizontalPager( pager, modifier.fillMaxSize(), @@ -383,8 +401,13 @@ fun TabPager(tabs: List, current: T, modifier: Modifier = Modifier, conte userScrollEnabled = false, key = { it }, ) { page -> - val owner = if (tabs[page] == current && backOwner != null) backOwner else noBack - CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides owner) { content(tabs[page]) } + val owner = if (tabs[page] == shown.value && backOwner != null) backOwner else noBack + val onScreen by remember(page) { + derivedStateOf { abs(pager.currentPage + pager.currentPageOffsetFraction - page) < 1f } + } + CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides owner, LocalOnScreen provides (pagerOnScreen && onScreen)) { + content(tabs[page]) + } } } diff --git a/docs/development-log.md b/docs/development-log.md index bd00389..5156c9f 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -1675,3 +1675,36 @@ This is the append-only implementation and handoff record shared by Codex and Cl - 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. + +## 2026-10-12 (continued) — TASK-069: the stall at the start of every move + +- Request: the product owner asked that the stutter be looked at on their phone directly. It was + driven over adb (taps, Back) and measured with `dumpsys gfxinfo framestats` and method + sampling (`am profile`), on the debug build, on the 120 Hz cover screen. +- What was found: every move began with one frame of 45 to 118 ms (5 to 14 frames lost). + 1. `LocalCodeWorkbench` is a static composition local and its value was built again on every + recomposition of the root, which every route change causes. A changed static local turns + skipping off beneath it, so all four tab screens were composed again on each move. + 2. The loading placeholder's light and the empty screen's cup kept animating on the tabs kept + composed off screen: the app drew 120 frames a second while nothing moved. + 3. Changing repository tab built the incoming list of rows in the frame the slide started on. + 4. A repository's branches replaced the list in place, with no movement, and Back built the + whole list again in one frame. +- What changed: the workbench value is remembered and made again only when its contents change. + `LocalOnScreen` tells a page whether it is on screen and the looping animations stop when it + is not. `MainActivity` hands the screens remembered callbacks and a remembered screen lambda. + The two repository lists sit in a `TabPager`, and so do the list and the branches of the + repository opened from it; branches that arrive during the slide wait for it to finish. +- Measured, first frame of the move, before to after: bottom tabs 80–118 ms to 23–34 ms; + repository tab 69–77 ms to 34–52 ms; idle, 120 frames a second to none. +- Not measured: the branches opening beside the list, and Back from them. The phone's screen was + turned off before that run; the change builds and passes the checks but has not been seen. + Before it, opening was a cut followed by a 49–59 ms frame when the branches arrived, and Back + was a cut with one frame of 69–105 ms. +- Design decision: the branches screen now slides in like a pushed screen, where it used to + replace the list at once. This is a visible change made for smoothness and is the product + owner's to keep or reverse. +- Checks and results: the five required checks pass. +- Caution: what remains (about 25 to 50 ms on the first frame) is in good part the debug build; + a non-debuggable build of the same code measured about a third less. R8 is still on hold + under TASK-016. diff --git a/tasks/069-in-app-motion.md b/tasks/069-in-app-motion.md index e1bddbc..d1c8622 100644 --- a/tasks/069-in-app-motion.md +++ b/tasks/069-in-app-motion.md @@ -34,6 +34,11 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c 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. +13. Measured on the product owner's device over adb (2026-10-12), at their request: every tab + change, repository tab change, opening and Back began with one frame of 45 to 118 ms on a + 120 Hz screen. A move now starts without every screen being drawn again; the two repository + lists stay built side by side; a repository's branches open beside the list and not in place + of it; nothing that loops is drawn while it is off screen. ## Scope @@ -58,7 +63,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 twelve items above is in the code and the app builds. +- [x] Each of the thirteen 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