From 8cf7acd9ac5ff208a9bf524872331123a4e9e4bd Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Thu, 27 Aug 2026 01:39:17 +0900 Subject: [PATCH] TASK-025: tabbed branch detail screen, repo commit time, swipeable tabs, and live-testing follow-up fixes - Branch/tag tap opens one screen with swipeable Files/History tabs (Route.BranchDetail replaces Route.CodeBrowser/Route.History) - Repository rows show last-commit time; repository All/Favorites tabs are swipeable - Fix overlapping commit-detail layout (BoxWithConstraints sibling stacking) - Fix BranchDetailScreen tab row jammed against status bar - Fix diff view horizontal scroll not working - Simplify references-loading message; remove in-app back buttons in favor of system back gesture; replace manual refresh buttons with pull-to-refresh Co-Authored-By: Claude Sonnet 5 --- .../java/com/ubisam/ubgitea/MainActivity.kt | 74 +++++++------- .../branchdetail/BranchDetailScreen.kt | 97 +++++++++++++++++++ .../feature/codebrowser/CodeBrowserScreen.kt | 14 ++- .../ubgitea/feature/history/HistoryScreen.kt | 36 +++---- .../notifications/NotificationsScreen.kt | 13 +-- .../repositories/RepositoryListScreen.kt | 65 ++++++++++--- .../feature/workitems/WorkItemsScreen.kt | 28 ++---- .../com/ubisam/ubgitea/ui/navigation/Route.kt | 8 +- .../ubgitea/ui/navigation/RouteStackSaver.kt | 13 +-- .../main/res/values/strings_branchdetail.xml | 4 + .../main/res/values/strings_codebrowser.xml | 1 - app/src/main/res/values/strings_history.xml | 1 - .../main/res/values/strings_notifications.xml | 1 - .../main/res/values/strings_repositories.xml | 3 +- app/src/main/res/values/strings_workitems.xml | 1 - .../ui/navigation/RouteEncodingTest.kt | 18 +--- docs/development-log.md | 13 +++ tasks/025-branch-detail-tabs.md | 78 +++++++++++++++ 18 files changed, 334 insertions(+), 134 deletions(-) create mode 100644 app/src/main/java/com/ubisam/ubgitea/feature/branchdetail/BranchDetailScreen.kt create mode 100644 app/src/main/res/values/strings_branchdetail.xml create mode 100644 tasks/025-branch-detail-tabs.md diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index 244f0c2..0534a50 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -16,15 +16,15 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryRef -import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserScreen +import com.ubisam.ubgitea.feature.branchdetail.BranchDetailScreen import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind -import com.ubisam.ubgitea.feature.history.HistoryScreen import com.ubisam.ubgitea.feature.history.HistoryTarget import com.ubisam.ubgitea.feature.history.HistoryViewModel import com.ubisam.ubgitea.feature.notifications.NotificationsScreen import com.ubisam.ubgitea.feature.notifications.NotificationsViewModel +import com.ubisam.ubgitea.feature.repositories.ReferenceKind import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen @@ -72,16 +72,12 @@ class MainActivity : ComponentActivity() { LaunchedEffect(currentRoute) { when (val route = currentRoute) { Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation. - is Route.CodeBrowser -> codeBrowserViewModel.open( - CodeBrowserTarget( - repository = RepositoryCoordinates.of(route.owner, route.name), - ref = RepositoryRef.of(route.ref), - kind = CodeReferenceKind.valueOf(route.refKind), - ), - ) - is Route.History -> historyViewModel.open( - HistoryTarget(RepositoryCoordinates.of(route.owner, route.name), RepositoryRef.of(route.ref)), - ) + is Route.BranchDetail -> { + val repository = RepositoryCoordinates.of(route.owner, route.name) + val ref = RepositoryRef.of(route.ref) + codeBrowserViewModel.open(CodeBrowserTarget(repository, ref, CodeReferenceKind.valueOf(route.refKind))) + historyViewModel.open(HistoryTarget(repository, ref)) + } is Route.WorkItems -> workItemsViewModel.open( WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name)), ) @@ -133,39 +129,35 @@ class MainActivity : ComponentActivity() { }, onAuthenticationExpired = ::authenticationExpired, ) - is Route.History -> HistoryScreen( - state = historyState, + is Route.BranchDetail -> BranchDetailScreen( + codeBrowserState = codeBrowserState, + onEntrySelected = codeBrowserViewModel::openEntry, + onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb, + onRetryCodeBrowser = codeBrowserViewModel::retry, + onCodeBrowserBack = { + if (!codeBrowserViewModel.navigateUp()) { + codeBrowserViewModel.close() + historyViewModel.close() + pop() + } + }, + historyState = historyState, onCommitSelected = historyViewModel::openCommit, - onNextPage = historyViewModel::nextPage, - onPreviousPage = historyViewModel::previousPage, - onRetryList = historyViewModel::retryList, - onRetryDetail = historyViewModel::retryDetail, + onHistoryNextPage = historyViewModel::nextPage, + onHistoryPreviousPage = historyViewModel::previousPage, + onRetryHistoryList = historyViewModel::retryList, + onRetryHistoryDetail = historyViewModel::retryDetail, onRetryPatch = historyViewModel::retryPatch, onFileSelected = historyViewModel::openFileDiff, onClearFileSelection = historyViewModel::clearFileSelection, - onBackToList = historyViewModel::backToList, - onBack = { + onHistoryBackToList = historyViewModel::backToList, + onHistoryBack = { + codeBrowserViewModel.close() historyViewModel.close() pop() }, onAuthenticationExpired = ::authenticationExpired, ) - is Route.CodeBrowser -> CodeBrowserScreen( - state = codeBrowserState, - onEntrySelected = codeBrowserViewModel::openEntry, - onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb, - onRetry = codeBrowserViewModel::retry, - onBack = { - if (!codeBrowserViewModel.navigateUp()) { - codeBrowserViewModel.close() - pop() - } - }, - onAuthenticationExpired = ::authenticationExpired, - onViewHistory = { target -> - push(Route.History(target.repository.owner, target.repository.name, target.ref.value)) - }, - ) Route.Repositories -> RepositoryListScreen( state = state, onSignIn = viewModel::signIn, @@ -175,7 +167,15 @@ class MainActivity : ComponentActivity() { onRepositorySelected = viewModel::selectRepository, onReferenceSelected = { repository, name, kind -> viewModel.selectReference(name, kind) - push(Route.History(repository.coordinates.owner, repository.coordinates.name, name)) + val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG + push( + Route.BranchDetail( + owner = repository.coordinates.owner, + name = repository.coordinates.name, + refKind = refKind.name, + ref = name, + ), + ) }, onReferencesRefresh = viewModel::refreshReferences, onBackToRepositories = viewModel::backToRepositories, diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/branchdetail/BranchDetailScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/branchdetail/BranchDetailScreen.kt new file mode 100644 index 0000000..0b647e1 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/branchdetail/BranchDetailScreen.kt @@ -0,0 +1,97 @@ +package com.ubisam.ubgitea.feature.branchdetail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.ubisam.ubgitea.R +import com.ubisam.ubgitea.domain.history.FileChange +import com.ubisam.ubgitea.domain.model.RepositoryPath +import com.ubisam.ubgitea.domain.model.TreeEntry +import com.ubisam.ubgitea.domain.model.CommitSummary +import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserScreen +import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserUiState +import com.ubisam.ubgitea.feature.history.HistoryScreen +import com.ubisam.ubgitea.feature.history.HistoryUiState +import kotlinx.coroutines.launch + +/** + * One branch/tag/commit ref, shown as two swipeable tabs — Files (the code browser, default) and + * History — over the same ref. Each tab keeps its own existing ViewModel and internal drill-down + * (a subfolder, a commit's detail) entirely as before; this screen only adds the tab chrome and + * the pager, and only intercepts "back" once a tab's own internal back-handling has nothing left + * to unwind (see `onCodeBrowserBack`/`onHistoryBack`). + */ +@Composable +fun BranchDetailScreen( + codeBrowserState: CodeBrowserUiState, + onEntrySelected: (TreeEntry) -> Unit, + onBreadcrumbSelected: (RepositoryPath) -> Unit, + onRetryCodeBrowser: () -> Unit, + onCodeBrowserBack: () -> Unit, + historyState: HistoryUiState, + onCommitSelected: (CommitSummary) -> Unit, + onHistoryNextPage: () -> Unit, + onHistoryPreviousPage: () -> Unit, + onRetryHistoryList: () -> Unit, + onRetryHistoryDetail: () -> Unit, + onRetryPatch: () -> Unit, + onFileSelected: (FileChange) -> Unit, + onClearFileSelection: () -> Unit, + onHistoryBackToList: () -> Unit, + onHistoryBack: () -> Unit, + onAuthenticationExpired: () -> Unit, + modifier: Modifier = Modifier, +) { + val pagerState = rememberPagerState(pageCount = { 2 }) + val scope = rememberCoroutineScope() + Column(modifier.fillMaxSize().statusBarsPadding()) { + TabRow(selectedTabIndex = pagerState.currentPage) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(stringResource(R.string.branchdetail_tab_files)) }, + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(stringResource(R.string.branchdetail_tab_history)) }, + ) + } + HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page -> + when (page) { + 0 -> CodeBrowserScreen( + state = codeBrowserState, + onEntrySelected = onEntrySelected, + onBreadcrumbSelected = onBreadcrumbSelected, + onRetry = onRetryCodeBrowser, + onBack = onCodeBrowserBack, + onAuthenticationExpired = onAuthenticationExpired, + onViewHistory = { scope.launch { pagerState.animateScrollToPage(1) } }, + ) + else -> HistoryScreen( + state = historyState, + onCommitSelected = onCommitSelected, + onNextPage = onHistoryNextPage, + onPreviousPage = onHistoryPreviousPage, + onRetryList = onRetryHistoryList, + onRetryDetail = onRetryHistoryDetail, + onRetryPatch = onRetryPatch, + onFileSelected = onFileSelected, + onClearFileSelection = onClearFileSelection, + onBackToList = onHistoryBackToList, + onBack = onHistoryBack, + onAuthenticationExpired = onAuthenticationExpired, + ) + } + } + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt index 80e6459..0e7cccd 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt @@ -54,11 +54,11 @@ fun CodeBrowserScreen( when (state) { CodeBrowserUiState.Idle -> Unit is CodeBrowserUiState.Loading -> { - BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected, onBack, onViewHistory) + BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected, onViewHistory) CenterMessage { CircularProgressIndicator() } } is CodeBrowserUiState.Directory -> { - BrowserHeader(state.target, state.path, false, onBreadcrumbSelected, onBack, onViewHistory) + BrowserHeader(state.target, state.path, false, onBreadcrumbSelected, onViewHistory) if (state.entries.isEmpty()) { CenterMessage { Text(stringResource(R.string.codebrowser_directory_empty)) } } else { @@ -70,11 +70,11 @@ fun CodeBrowserScreen( } } is CodeBrowserUiState.File -> { - BrowserHeader(state.target, state.path, true, onBreadcrumbSelected, onBack, onViewHistory) + BrowserHeader(state.target, state.path, true, onBreadcrumbSelected, onViewHistory) FileContent(state.content) } is CodeBrowserUiState.UnsupportedEntry -> { - BrowserHeader(state.target, state.path, true, onBreadcrumbSelected, onBack, onViewHistory) + BrowserHeader(state.target, state.path, true, onBreadcrumbSelected, onViewHistory) CenterMessage { Text( stringResource( @@ -85,7 +85,7 @@ fun CodeBrowserScreen( } } is CodeBrowserUiState.Error -> { - BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected, onBack, onViewHistory) + BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected, onViewHistory) CenterMessage { Text(stringResource(state.type.messageResource)) if (state.type == CodeBrowserError.AUTHENTICATION_EXPIRED) { @@ -108,13 +108,11 @@ private fun BrowserHeader( path: RepositoryPath, leafIsFile: Boolean, onBreadcrumbSelected: (RepositoryPath) -> Unit, - onBack: () -> Unit, onViewHistory: (CodeBrowserTarget) -> Unit, ) { Column(Modifier.fillMaxWidth().padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedButton(onClick = onBack) { Text(stringResource(R.string.codebrowser_back)) } - Column(Modifier.padding(start = 12.dp).weight(1f)) { + Column(Modifier.weight(1f)) { Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge) val refKind = when (target.kind) { CodeReferenceKind.BRANCH -> stringResource(R.string.codebrowser_ref_branch) diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt index 77e3873..d307a3f 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt @@ -73,12 +73,12 @@ fun HistoryScreen( HistoryUiState.Idle -> Unit is HistoryUiState.Loading -> { BackHandler(onBack = onBack) - Header(state.target, onBack) + Header(state.target) CenterMessage { CircularProgressIndicator() } } is HistoryUiState.CommitList -> { BackHandler(onBack = onBack) - Header(state.target, onBack) + Header(state.target) if (state.commits.isEmpty()) { CenterMessage { Text(stringResource(R.string.history_empty)) } } else { @@ -92,7 +92,7 @@ fun HistoryScreen( } is HistoryUiState.CommitDetailLoading -> { BackHandler(onBack = onBackToList) - Header(state.target, onBackToList) + Header(state.target) CenterMessage { CircularProgressIndicator() } } is HistoryUiState.CommitDetailContent -> { @@ -100,7 +100,7 @@ fun HistoryScreen( } is HistoryUiState.Error -> { BackHandler(onBack = onBack) - Header(state.target, onBack) + Header(state.target) CenterMessage { Text(stringResource(state.type.messageResource)) if (state.type == HistoryError.AUTHENTICATION_EXPIRED) { @@ -118,15 +118,10 @@ fun HistoryScreen( } @Composable -private fun Header(target: HistoryTarget, onBack: () -> Unit) { +private fun Header(target: HistoryTarget) { Column(Modifier.fillMaxWidth().padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedButton(onClick = onBack) { Text(stringResource(R.string.history_back)) } - Column(Modifier.padding(start = 12.dp)) { - Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge) - Text(stringResource(R.string.history_ref, target.ref.value), style = MaterialTheme.typography.bodyMedium) - } - } + Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge) + Text(stringResource(R.string.history_ref, target.ref.value), style = MaterialTheme.typography.bodyMedium) } HorizontalDivider() } @@ -166,7 +161,7 @@ private fun CommitDetailScreen( BackHandler(onBack = onBackToList) Row(Modifier.fillMaxSize()) { Column(Modifier.weight(1f)) { - Header(state.target, onBackToList) + Header(state.target) CommitDetailView(state, onRetryPatch, onFileSelected) } VerticalDivider() @@ -181,12 +176,16 @@ private fun CommitDetailScreen( } } else if (state.selectedFile != null) { BackHandler(onBack = onClearFileSelection) - Header(state.target, onClearFileSelection) - FileDiffView(state.selectedFile) + Column(Modifier.fillMaxSize()) { + Header(state.target) + FileDiffView(state.selectedFile) + } } else { BackHandler(onBack = onBackToList) - Header(state.target, onBackToList) - CommitDetailView(state, onRetryPatch, onFileSelected) + Column(Modifier.fillMaxSize()) { + Header(state.target) + CommitDetailView(state, onRetryPatch, onFileSelected) + } } } } @@ -324,7 +323,8 @@ private fun DiffSideLine(line: DiffLine?, isGap: Boolean, horizontalScrollState: fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall, color = color, - modifier = Modifier.fillMaxWidth().horizontalScroll(horizontalScrollState).padding(horizontal = 16.dp, vertical = 2.dp), + softWrap = false, + modifier = Modifier.horizontalScroll(horizontalScrollState).padding(horizontal = 16.dp, vertical = 2.dp), ) } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt index fe6396d..475ac68 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt @@ -50,11 +50,11 @@ fun NotificationsScreen( when (state) { NotificationsUiState.Idle -> Unit NotificationsUiState.Loading -> { - FilterTabs(unreadOnly = true, onShowUnreadOnly, onShowAll, onBack) + FilterTabs(unreadOnly = true, onShowUnreadOnly, onShowAll) CenterMessage { CircularProgressIndicator() } } is NotificationsUiState.Content -> { - FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll, onBack) + FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll) LazyColumn(Modifier.weight(1f)) { items(state.items, key = NotificationSummary::id) { notification -> NotificationRow(notification) { onNotificationSelected(notification) } @@ -63,11 +63,11 @@ fun NotificationsScreen( PageFooter(state.page, state.page > 1, state.hasNextPage, onPreviousPage, onNextPage) } is NotificationsUiState.Empty -> { - FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll, onBack) + FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll) CenterMessage { Text(stringResource(R.string.notifications_empty)) } } is NotificationsUiState.Error -> { - FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll, onBack) + FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll) ErrorMessage(state.type, onRetry, onAuthenticationExpired) } } @@ -76,10 +76,7 @@ fun NotificationsScreen( } @Composable -private fun FilterTabs(unreadOnly: Boolean, onShowUnreadOnly: () -> Unit, onShowAll: () -> Unit, onBack: () -> Unit) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) { - OutlinedButton(onClick = onBack) { Text(stringResource(R.string.notifications_back)) } - } +private fun FilterTabs(unreadOnly: Boolean, onShowUnreadOnly: () -> Unit, onShowAll: () -> Unit) { TabRow(selectedTabIndex = if (unreadOnly) 0 else 1) { Tab(selected = unreadOnly, onClick = onShowUnreadOnly, text = { Text(stringResource(R.string.notifications_filter_unread)) }) Tab(selected = !unreadOnly, onClick = onShowAll, text = { Text(stringResource(R.string.notifications_filter_all)) }) 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 3737532..61d3b88 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable @@ -16,10 +17,12 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.TabRow @@ -28,6 +31,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.semantics.Role @@ -77,7 +81,10 @@ fun RepositoryListScreen( stringResource(R.string.repositories_loading), Modifier.padding(padding), ) - is RepositoryListUiState.Empty -> Column(Modifier.padding(padding).fillMaxSize()) { + is RepositoryListUiState.Empty -> Column( + Modifier.padding(padding).fillMaxSize() + .modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites), + ) { ModeTabs(state.mode, onShowAllRepositories, onShowFavorites) MessageState( if (state.mode == RepositoryListMode.FAVORITES) { @@ -122,7 +129,7 @@ fun RepositoryListScreen( is RepositoryListUiState.ReferencesLoading -> { BackHandler(onBack = onBackToRepositories) LoadingState( - stringResource(R.string.repositories_references_loading, state.repository.name), + stringResource(R.string.repositories_references_loading), Modifier.padding(padding), ) } @@ -132,7 +139,6 @@ fun RepositoryListScreen( state, onReferenceSelected, onReferencesRefresh, - onBackToRepositories, onLogout, onViewWorkItems, Modifier.padding(padding), @@ -187,6 +193,7 @@ private fun MessageState( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RepositoryContent( state: RepositoryListUiState.Content, @@ -202,7 +209,14 @@ private fun RepositoryContent( onViewNotifications: () -> Unit, modifier: Modifier = Modifier, ) { - LazyColumn(modifier = modifier.fillMaxSize()) { + PullToRefreshBox( + isRefreshing = false, + onRefresh = onRefreshRepositories, + modifier = modifier.fillMaxSize(), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize().modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites), + ) { item { Row( Modifier.fillMaxWidth().padding(16.dp), @@ -214,9 +228,6 @@ private fun RepositoryContent( } Spacer(Modifier.weight(1f)) OutlinedButton(onClick = onViewNotifications) { Text(stringResource(R.string.repositories_notifications)) } - OutlinedButton(onClick = onRefreshRepositories, modifier = Modifier.padding(start = 8.dp)) { - Text(stringResource(R.string.repositories_refresh)) - } OutlinedButton(onClick = onLogout, modifier = Modifier.padding(start = 8.dp)) { Text(stringResource(R.string.repositories_logout)) } @@ -246,6 +257,10 @@ private fun RepositoryContent( } ?: stringResource(R.string.repositories_no_default_branch), style = MaterialTheme.typography.bodySmall, ) + Text( + stringResource(R.string.repositories_last_commit_at, formatInstant(repository.updatedAtEpochMillis)), + style = MaterialTheme.typography.bodySmall, + ) LastPushedBranchRow(repository, onRetryLastPushedBranch) } HorizontalDivider() @@ -266,11 +281,37 @@ private fun RepositoryContent( } } } + } } internal fun repositoryLazyListKey(coordinates: RepositoryCoordinates): String = "${coordinates.owner}/${coordinates.name}" +// detectHorizontalDragGestures only claims a gesture once its motion resolves as predominantly +// horizontal, so this coexists with LazyColumn's own vertical scroll rather than fighting it. +private fun Modifier.modeSwipeGesture( + mode: RepositoryListMode, + onShowAllRepositories: () -> Unit, + onShowFavorites: () -> Unit, +): Modifier = pointerInput(mode) { + var totalDragX = 0f + detectHorizontalDragGestures( + onDragStart = { totalDragX = 0f }, + onHorizontalDrag = { change, dragAmount -> + change.consume() + totalDragX += dragAmount + }, + onDragEnd = { + when { + totalDragX > SwipeThresholdPx && mode == RepositoryListMode.ALL -> onShowFavorites() + totalDragX < -SwipeThresholdPx && mode == RepositoryListMode.FAVORITES -> onShowAllRepositories() + } + }, + ) +} + +private const val SwipeThresholdPx = 120f + @Composable private fun OrganizationFilterRow( selected: String?, @@ -339,24 +380,24 @@ private fun LastPushedBranchRow( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReferencesContent( state: RepositoryListUiState.ReferencesContent, onReferenceSelected: (RepositoryRowUiModel, String, ReferenceKind) -> Unit, onRefresh: () -> Unit, - onBack: () -> Unit, onLogout: () -> Unit, onViewWorkItems: (RepositoryRowUiModel) -> Unit, modifier: Modifier = Modifier, ) { - LazyColumn(modifier = modifier.fillMaxSize()) { + PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = modifier.fillMaxSize()) { + LazyColumn(modifier = Modifier.fillMaxSize()) { item { Row( Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { - OutlinedButton(onClick = onBack) { Text(stringResource(R.string.repositories_back)) } - Column(Modifier.padding(start = 12.dp).weight(1f)) { + Column(Modifier.weight(1f)) { Text("${state.repository.owner}/${state.repository.name}", style = MaterialTheme.typography.titleLarge) Text(stringResource(R.string.repositories_account, state.accountName)) } @@ -365,7 +406,6 @@ private fun ReferencesContent( } item { Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = onRefresh) { Text(stringResource(R.string.repositories_refresh)) } OutlinedButton(onClick = { onViewWorkItems(state.repository) }) { Text(stringResource(R.string.repositories_work_items)) } @@ -398,6 +438,7 @@ private fun ReferencesContent( ReferenceRow(reference) { name, kind -> onReferenceSelected(state.repository, name, kind) } } } + } } @Composable 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 bfed880..bd78cef 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 @@ -58,12 +58,12 @@ fun WorkItemsScreen( WorkItemsUiState.Idle -> Unit is WorkItemsUiState.Loading -> { BackHandler(onBack = onBack) - ModeTabs(state.tab, onShowIssues, onShowPullRequests, onBack) + ModeTabs(state.tab, onShowIssues, onShowPullRequests) CenterMessage { CircularProgressIndicator() } } is WorkItemsUiState.IssueList -> { BackHandler(onBack = onBack) - ModeTabs(WorkItemsTab.ISSUES, onShowIssues, onShowPullRequests, onBack) + ModeTabs(WorkItemsTab.ISSUES, onShowIssues, onShowPullRequests) LazyColumn(Modifier.weight(1f)) { items(state.items, key = IssueSummary::number) { issue -> IssueRow(issue) { onIssueSelected(issue) } @@ -73,7 +73,7 @@ fun WorkItemsScreen( } is WorkItemsUiState.PullRequestList -> { BackHandler(onBack = onBack) - ModeTabs(WorkItemsTab.PULL_REQUESTS, onShowIssues, onShowPullRequests, onBack) + ModeTabs(WorkItemsTab.PULL_REQUESTS, onShowIssues, onShowPullRequests) LazyColumn(Modifier.weight(1f)) { items(state.items, key = PullRequestSummary::number) { pr -> PullRequestRow(pr) { onPullRequestSelected(pr) } @@ -83,37 +83,32 @@ fun WorkItemsScreen( } is WorkItemsUiState.Empty -> { BackHandler(onBack = onBack) - ModeTabs(state.tab, onShowIssues, onShowPullRequests, onBack) + ModeTabs(state.tab, onShowIssues, onShowPullRequests) CenterMessage { Text(stringResource(R.string.workitems_empty)) } } is WorkItemsUiState.ListError -> { BackHandler(onBack = onBack) - ModeTabs(state.tab, onShowIssues, onShowPullRequests, onBack) + ModeTabs(state.tab, onShowIssues, onShowPullRequests) ErrorMessage(state.type, onRetryList, onAuthenticationExpired) } is WorkItemsUiState.IssueDetailLoading -> { BackHandler(onBack = onBackToList) - DetailHeader(onBackToList) CenterMessage { CircularProgressIndicator() } } is WorkItemsUiState.IssueDetailContent -> { BackHandler(onBack = onBackToList) - DetailHeader(onBackToList) IssueDetailView(state) } is WorkItemsUiState.PullRequestDetailLoading -> { BackHandler(onBack = onBackToList) - DetailHeader(onBackToList) CenterMessage { CircularProgressIndicator() } } is WorkItemsUiState.PullRequestDetailContent -> { BackHandler(onBack = onBackToList) - DetailHeader(onBackToList) PullRequestDetailView(state) } is WorkItemsUiState.DetailError -> { BackHandler(onBack = onBackToList) - DetailHeader(onBackToList) ErrorMessage(state.type, onRetryDetail, onAuthenticationExpired) } } @@ -122,10 +117,7 @@ fun WorkItemsScreen( } @Composable -private fun ModeTabs(tab: WorkItemsTab, onShowIssues: () -> Unit, onShowPullRequests: () -> Unit, onBack: () -> Unit) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) { - OutlinedButton(onClick = onBack) { Text(stringResource(R.string.workitems_back)) } - } +private fun ModeTabs(tab: WorkItemsTab, onShowIssues: () -> Unit, onShowPullRequests: () -> Unit) { TabRow(selectedTabIndex = if (tab == WorkItemsTab.PULL_REQUESTS) 1 else 0) { Tab( selected = tab == WorkItemsTab.ISSUES, @@ -140,14 +132,6 @@ private fun ModeTabs(tab: WorkItemsTab, onShowIssues: () -> Unit, onShowPullRequ } } -@Composable -private fun DetailHeader(onBack: () -> Unit) { - Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedButton(onClick = onBack) { Text(stringResource(R.string.workitems_back)) } - } - HorizontalDivider() -} - @Composable private fun IssueRow(issue: IssueSummary, onSelected: () -> Unit) { Column(Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 12.dp)) { diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/navigation/Route.kt b/app/src/main/java/com/ubisam/ubgitea/ui/navigation/Route.kt index ebc7445..8649baf 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/navigation/Route.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/navigation/Route.kt @@ -8,8 +8,12 @@ package com.ubisam.ubgitea.ui.navigation */ sealed interface Route { data object Repositories : Route - data class CodeBrowser(val owner: String, val name: String, val refKind: String, val ref: String) : Route - data class History(val owner: String, val name: String, val ref: String) : Route + + // A single screen hosting two swipeable tabs — Files (the code browser) and History — over + // one branch/tag/commit ref. Each tab's own internal drill-down (a subfolder, a commit's + // detail) is handled entirely inside that tab's existing ViewModel state machine, not by + // pushing further routes, so this one route covers the whole branch-detail experience. + data class BranchDetail(val owner: String, val name: String, val refKind: String, val ref: String) : Route data class WorkItems(val owner: String, val name: String) : Route data object Notifications : Route } diff --git a/app/src/main/java/com/ubisam/ubgitea/ui/navigation/RouteStackSaver.kt b/app/src/main/java/com/ubisam/ubgitea/ui/navigation/RouteStackSaver.kt index 7d0b106..4059058 100644 --- a/app/src/main/java/com/ubisam/ubgitea/ui/navigation/RouteStackSaver.kt +++ b/app/src/main/java/com/ubisam/ubgitea/ui/navigation/RouteStackSaver.kt @@ -22,15 +22,13 @@ val RouteStackSaver: Saver, Any> = listSaver( internal object RouteEncoding { private const val FIELD_SEPARATOR = '' private const val TYPE_REPOSITORIES = "repositories" - private const val TYPE_CODE_BROWSER = "codeBrowser" - private const val TYPE_HISTORY = "history" + private const val TYPE_BRANCH_DETAIL = "branchDetail" private const val TYPE_WORK_ITEMS = "workItems" private const val TYPE_NOTIFICATIONS = "notifications" fun encode(route: Route): String = when (route) { Route.Repositories -> TYPE_REPOSITORIES - is Route.CodeBrowser -> join(TYPE_CODE_BROWSER, route.owner, route.name, route.refKind, route.ref) - is Route.History -> join(TYPE_HISTORY, route.owner, route.name, route.ref) + is Route.BranchDetail -> join(TYPE_BRANCH_DETAIL, route.owner, route.name, route.refKind, route.ref) is Route.WorkItems -> join(TYPE_WORK_ITEMS, route.owner, route.name) Route.Notifications -> TYPE_NOTIFICATIONS } @@ -40,11 +38,8 @@ internal object RouteEncoding { fun decodeOrNull(encoded: String): Route? { val parts = encoded.split(FIELD_SEPARATOR) return when (parts.first()) { - TYPE_CODE_BROWSER -> parts.takeIf { it.size == 5 }?.let { - Route.CodeBrowser(owner = it[1], name = it[2], refKind = it[3], ref = it[4]) - } - TYPE_HISTORY -> parts.takeIf { it.size == 4 }?.let { - Route.History(owner = it[1], name = it[2], ref = it[3]) + TYPE_BRANCH_DETAIL -> parts.takeIf { it.size == 5 }?.let { + Route.BranchDetail(owner = it[1], name = it[2], refKind = it[3], ref = it[4]) } TYPE_WORK_ITEMS -> parts.takeIf { it.size == 3 }?.let { Route.WorkItems(owner = it[1], name = it[2]) diff --git a/app/src/main/res/values/strings_branchdetail.xml b/app/src/main/res/values/strings_branchdetail.xml new file mode 100644 index 0000000..5e075d0 --- /dev/null +++ b/app/src/main/res/values/strings_branchdetail.xml @@ -0,0 +1,4 @@ + + 파일 + 히스토리 + diff --git a/app/src/main/res/values/strings_codebrowser.xml b/app/src/main/res/values/strings_codebrowser.xml index aebeced..0143b7d 100644 --- a/app/src/main/res/values/strings_codebrowser.xml +++ b/app/src/main/res/values/strings_codebrowser.xml @@ -1,6 +1,5 @@ - 뒤로 %1$s · %2$s 브랜치 태그 diff --git a/app/src/main/res/values/strings_history.xml b/app/src/main/res/values/strings_history.xml index 4b31d59..cb9bb50 100644 --- a/app/src/main/res/values/strings_history.xml +++ b/app/src/main/res/values/strings_history.xml @@ -1,5 +1,4 @@ - 뒤로 기준: %1$s 커밋 내역이 없습니다. 다시 시도 diff --git a/app/src/main/res/values/strings_notifications.xml b/app/src/main/res/values/strings_notifications.xml index 7a017e8..a5d67d2 100644 --- a/app/src/main/res/values/strings_notifications.xml +++ b/app/src/main/res/values/strings_notifications.xml @@ -1,7 +1,6 @@ 알림 알림 - 뒤로 읽지 않음 전체 [읽지 않음] diff --git a/app/src/main/res/values/strings_repositories.xml b/app/src/main/res/values/strings_repositories.xml index 763bbbd..3b87563 100644 --- a/app/src/main/res/values/strings_repositories.xml +++ b/app/src/main/res/values/strings_repositories.xml @@ -19,8 +19,9 @@ 공개 기본 브랜치: %1$s 기본 브랜치 없음 + 마지막 커밋: %1$s %1$s 계정 - %1$s 저장소의 브랜치와 태그를 불러오는 중입니다. + 로딩 중 브랜치 브랜치가 없습니다. 태그 diff --git a/app/src/main/res/values/strings_workitems.xml b/app/src/main/res/values/strings_workitems.xml index 785f056..512c486 100644 --- a/app/src/main/res/values/strings_workitems.xml +++ b/app/src/main/res/values/strings_workitems.xml @@ -1,5 +1,4 @@ - 뒤로 이슈 Pull Request 표시할 항목이 없습니다. diff --git a/app/src/test/java/com/ubisam/ubgitea/ui/navigation/RouteEncodingTest.kt b/app/src/test/java/com/ubisam/ubgitea/ui/navigation/RouteEncodingTest.kt index 88fc1f2..2c6e0b1 100644 --- a/app/src/test/java/com/ubisam/ubgitea/ui/navigation/RouteEncodingTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/ui/navigation/RouteEncodingTest.kt @@ -11,14 +11,8 @@ class RouteEncodingTest { } @Test - fun `code browser route round-trips including a ref containing a slash`() { - val route = Route.CodeBrowser(owner = "team name", name = "repo+#", refKind = "BRANCH", ref = "refs/heads/feature/a b") - assertEquals(route, RouteEncoding.decode(RouteEncoding.encode(route))) - } - - @Test - fun `history route round-trips`() { - val route = Route.History(owner = "owner", name = "repo", ref = "main") + fun `branch detail route round-trips including a ref containing a slash`() { + val route = Route.BranchDetail(owner = "team name", name = "repo+#", refKind = "BRANCH", ref = "refs/heads/feature/a b") assertEquals(route, RouteEncoding.decode(RouteEncoding.encode(route))) } @@ -35,17 +29,15 @@ class RouteEncodingTest { @Test fun `truncated persisted routes fall back instead of indexing missing fields`() { - assertEquals(Route.Repositories, RouteEncoding.decode("codeBrowser\u001fowner")) - assertEquals(Route.Repositories, RouteEncoding.decode("history\u001fowner\u001frepo")) - assertEquals(Route.Repositories, RouteEncoding.decode("workItems\u001fowner")) + assertEquals(Route.Repositories, RouteEncoding.decode("branchDetail" + '' + "owner")) + assertEquals(Route.Repositories, RouteEncoding.decode("workItems" + '' + "owner")) } @Test fun `a full back stack round-trips through the saver in order`() { val stack = listOf( Route.Repositories, - Route.CodeBrowser("owner", "repo", "BRANCH", "main"), - Route.History("owner", "repo", "main"), + Route.BranchDetail("owner", "repo", "BRANCH", "main"), ) val scope = SaverScope { true } diff --git a/docs/development-log.md b/docs/development-log.md index 317ebe3..4f2e301 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -359,3 +359,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl - Known limitations: File browsing (`Route.CodeBrowser`) is now unreachable from the UI — the reference screen was its only entry point, and item 1 redirected that tap to history instead. This is disclosed in the task file as a known, explicit side effect, not an oversight; a follow-up task (requested live, not yet started) will reintroduce it as a "Files" tab alongside "History" on the branch/tag detail screen. One live-reported rendering glitch (two screens appearing to overlap momentarily) has not been independently reproduced or root-caused. A final, single closing device-confirmation pass covering every item above together has not yet been explicitly requested and received. - Next recommended task: Several more live requests are already queued from the same testing session: a tabbed Files/History branch-detail screen (bringing `Route.CodeBrowser` back into reach), an image-file viewer in the code browser, numbered pagination with direct page-entry for the Issues/PR list (repository/history/notification lists keep prev/next, since Gitea doesn't provide a total count for those endpoints), swipeable 전체/즐겨찾기 tabs on the repository list, and showing each repository row's last-commit time. - Next owner: Claude, continuing through the queued live requests above. + +## 2026-08-27 — TASK-025: tabbed branch detail screen, repository commit time, swipeable repo/favorites tabs, and six live-testing follow-up fixes + +- Agent and role: Claude, continuing the same live testing session, implementing the previously-queued Files/History tab request plus a further rapid sequence of live requests and bug reports from the human product owner while they kept testing on their own Galaxy Z Fold over wireless `adb`. +- Commit/diff: continued on `claude/task-015-notifications`; see `tasks/025-branch-detail-tabs.md` for the full file list. `Route.CodeBrowser`/`Route.History` replaced by one `Route.BranchDetail`; new `feature/branchdetail/BranchDetailScreen.kt` and `strings_branchdetail.xml`; touch-ups across `feature/history/HistoryScreen.kt`, `feature/codebrowser/CodeBrowserScreen.kt`, `feature/notifications/NotificationsScreen.kt`, `feature/workitems/WorkItemsScreen.kt`, `feature/repositories/RepositoryListScreen.kt`, and their string resources. +- Features implemented: (1) tapping a branch/tag now opens one screen with two swipeable tabs, 파일 (Files, the restored TASK-006 code browser) and 히스토리 (History), instead of jumping straight to history as TASK-024 left it; (2) each repository row shows its last-commit time, reusing the existing `updatedAtEpochMillis` field; (3) the repository list's 전체/즐겨찾기 tabs are now swipeable, not just tap-to-switch; (4) fixed a live-reported overlapping-screen rendering bug in the commit-detail narrow layout (root cause: a `BoxWithConstraints` branch had multiple children as bare siblings instead of wrapped in a `Column`, so they stacked like `Box` children instead of arranging vertically); (5) fixed `BranchDetailScreen`'s tab row rendering jammed against the status bar (it was the only top-level screen without its own `Scaffold` or explicit inset padding); (6) fixed horizontal scroll not working in the diff view (`fillMaxWidth()` before `horizontalScroll()` plus missing `softWrap = false` meant lines wrapped instead of overflowing); (7) simplified the references-loading message to a plain "로딩 중"; (8) removed every in-app screen-header back button in favor of the system back gesture already wired via each screen's `BackHandler`, including deleting `WorkItemsScreen`'s back-button-only `DetailHeader` composable entirely; (9) replaced manual "새로고침" buttons on the repository and references lists with `PullToRefreshBox` pull-to-refresh. +- How it was implemented: Item 1's `BranchDetailScreen` wraps the existing `CodeBrowserScreen`/`HistoryScreen` unmodified in a `TabRow` + `HorizontalPager`, sharing one exit point (`onCodeBrowserBack`/`onHistoryBack` both close both ViewModels and pop the route) so their own internal `BackHandler`s never need extra coordination. Items 4-6 were each diagnosed from a live bug report (two of them via screenshot) rather than found proactively — item 4 specifically required disproving an initial wrong hypothesis (an `adb` force-stop/restart rendering artifact) after the human product owner reproduced it a second time following a clean manual relaunch, then confirming via `adb shell dumpsys activity` that only one window existed before looking at the Compose layout code itself. Item 9 hit a compile error (`PullToRefreshBox` is a Material3 experimental API) resolved by adding `@OptIn(ExperimentalMaterial3Api::class)` to both composables that use it. +- Files changed: see `tasks/025-branch-detail-tabs.md`'s Scope section for the full list. +- Checks and results: `./gradlew compileDebugKotlin` BUILD SUCCESSFUL (confirming the `PullToRefreshBox` OptIn fix); `./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks` BUILD SUCCESSFUL in 3m 22s, 53 actionable tasks executed, all unit tests passing, 0 lint errors. Build installed on the human product owner's device over wireless `adb`. +- Review findings and resolutions: None from a separate review pass yet — self-review happened via live device testing, which is how items 4 and 6's underlying bugs were actually caught. The `repositories_back` string was deliberately kept (not removed alongside the other `*_back` strings) since it's a distinct error-state recovery action, not a header back button. +- Known limitations: Full device confirmation of this whole batch together (tabs, swipe, overlap fix, status-bar padding, diff scroll, back-button removal, pull-to-refresh) in one pass is still pending as of this commit — the individual fixes were each verified against their own specific report, but not yet re-confirmed together. Two items from the queued list are still not started: an image-file viewer in the code browser, and numbered pagination with direct page-entry for the Issues/PR list. +- Next recommended task: Get the human product owner's live confirmation of this batch, then pick up the two remaining queued items (image viewer, Issues/PR numbered pagination). +- Next owner: Claude, continuing per the standing direction. diff --git a/tasks/025-branch-detail-tabs.md b/tasks/025-branch-detail-tabs.md new file mode 100644 index 0000000..fa22a79 --- /dev/null +++ b/tasks/025-branch-detail-tabs.md @@ -0,0 +1,78 @@ +# TASK-025: Tabbed branch detail screen (Files + History), repository row commit time, swipeable repository/favorites tabs, and live-testing follow-up fixes + +Status: implemented; all required checks pass; device verification pending +Owner: Claude +Reviewer: Codex + +## Outcome + +Three more live requests from the same testing session: + +1. Tapping a branch/tag now opens one screen with two swipeable tabs — **파일** (Files, the code browser, default) and **히스토리** (History) — over the same ref, restoring file/folder browsing (removed from the UI by TASK-024) while keeping the direct-to-history behavior the human product owner asked for, by making history the second tab instead of the only destination. +2. Each repository row on the repository list now shows its last-commit time (reusing the existing `updatedAtEpochMillis` field and the shared `formatInstant` helper from TASK-024 — no new API data needed). +3. The repository list's 전체(All)/즐겨찾기(Favorites) tabs are now swipeable (swipe right from All → Favorites, swipe left from Favorites → All), not just tap-to-switch. + +Plus six more follow-up fixes/changes found or requested during the same live-testing pass on the same branch, kept in this task rather than split out since they're all small, tightly related touch-ups to screens this task already modifies or reuses: + +4. **Overlap bug fix**: the narrow-layout (non-Z-Fold-unfolded) branch of `CommitDetailScreen`'s `BoxWithConstraints` had `Header(...)` and its content as bare siblings instead of children of a `Column`, so `BoxWithConstraints` (which behaves like `Box` — it does not arrange multi-child branches vertically on its own) rendered them stacked on top of each other. Reported twice by the human product owner via screenshot before being root-caused; fixed by wrapping each branch's contents in `Column(Modifier.fillMaxSize()) { ... }`. +5. **Status-bar padding fix**: `BranchDetailScreen`'s `TabRow` was jammed against the status bar because, unlike every other top-level screen in the app, it wasn't wrapped in its own `Scaffold`. Fixed with `Modifier.statusBarsPadding()` on the outer `Column` (not a nested `Scaffold`, to avoid double-padding since `CodeBrowserScreen`/`HistoryScreen` already have their own `Scaffold`s internally). +6. **Diff horizontal-scroll fix**: `DiffSideLine` (in the History tab's file-diff view) had `Modifier.fillMaxWidth().horizontalScroll(state)` and no `softWrap = false`, so long lines wrapped instead of overflowing — nothing to scroll. Fixed by removing `fillMaxWidth()` and adding `softWrap = false`, matching the already-working `PatchText` composable's modifier pattern. +7. **Loading-text simplification**: the references-loading message on the repository screen previously interpolated the repository name into a longer sentence; simplified to a plain "로딩 중" per direct request, and the now-unused `%1$s` placeholder removed from `repositories_references_loading`. +8. **In-app back buttons removed**: every screen-header back button (`CodeBrowserScreen`'s `BrowserHeader`, `HistoryScreen`'s `Header`, `NotificationsScreen`'s `FilterTabs`, `WorkItemsScreen`'s `ModeTabs` and `DetailHeader`, `RepositoryListScreen`'s `ReferencesContent` header) removed, since every screen already has a `BackHandler` wired to the system back gesture and the human product owner asked to rely on that instead. `WorkItemsScreen`'s `DetailHeader` composable (which consisted only of a back button) removed entirely along with all 5 call sites. Now-unused `history_back`/`notifications_back`/`workitems_back`/`codebrowser_back` string resources removed; `repositories_back` kept, since it's a distinct recovery action on the references-error state's secondary button, not a header back button. +9. **Pull-to-refresh replaces manual refresh buttons**: `RepositoryListScreen`'s `RepositoryContent` and `ReferencesContent` each had a manual "새로고침" `OutlinedButton`; both replaced with `PullToRefreshBox` (Material3, `@OptIn(ExperimentalMaterial3Api::class)`) wrapping their `LazyColumn`s, so pulling down from the top refreshes instead. + +## Scope + +- Included: `ui/navigation/Route.kt`/`RouteStackSaver.kt` (`Route.CodeBrowser`/`Route.History` replaced by one `Route.BranchDetail`), new `feature/branchdetail/BranchDetailScreen.kt`, `MainActivity.kt` wiring, `strings_branchdetail.xml`, plus the follow-up touch-ups (items 4-9 above) to `feature/history/HistoryScreen.kt`, `feature/codebrowser/CodeBrowserScreen.kt`, `feature/notifications/NotificationsScreen.kt`, `feature/workitems/WorkItemsScreen.kt`, `feature/repositories/RepositoryListScreen.kt`, and their string resources. +- Excluded: No change to `CodeBrowserViewModel`/`HistoryViewModel` internals — both are reused exactly as they were, including their own internal drill-down (subfolder navigation, commit detail) and their own per-state `BackHandler`s. This screen only adds the `TabRow` + `HorizontalPager` chrome around them. No change to actual refresh/pagination/network logic — item 9 only changes the trigger UI, not what `onRefresh` does. + +## Design notes + +- Both tabs' ViewModels (`codeBrowserViewModel`, `historyViewModel`) are opened together when `Route.BranchDetail` becomes current, and closed together when either tab's internal back-handling has nothing left to unwind (`onCodeBrowserBack`/`onHistoryBack` both close both ViewModels and pop the route) — a single shared exit point rather than two independently-tracked one. +- `HorizontalPager`'s default `beyondViewportPageCount` (0) means only the currently visible tab is composed, so each tab's own internal `BackHandler`s never conflict with each other — the system back button always applies to whichever tab is actually on screen, with no extra coordination code needed. +- `CodeBrowserScreen`'s existing "히스토리" header button is kept, repurposed to animate the pager to the History tab instead of pushing a route — a working existing affordance, not dead code. +- `Route.CodeBrowser` and `Route.History` are fully removed (not left dead), since `Route.BranchDetail` is now the only way either screen is reached. + +## Acceptance criteria + +- [x] Tapping a branch/tag opens `Route.BranchDetail`, defaulting to the Files tab. +- [x] Files tab supports folder drill-down and reading file contents (the restored TASK-006 code browser, unmodified). +- [x] Swiping left/right between tabs works (`HorizontalPager`); tapping a tab also works (`TabRow`). +- [x] System back: within a subfolder/commit-detail, back unwinds that tab's own internal state first; only at each tab's own root does back exit the branch-detail screen entirely. +- [x] `RouteEncoding`/`RouteStackSaver` round-trip `Route.BranchDetail` including a ref containing a slash, and fall back to `Repositories` on truncated/malformed persisted data. +- [x] Commit-detail screen renders correctly (no overlapping content) in the narrow/folded layout. +- [x] `BranchDetailScreen`'s tab row sits below the status bar, not jammed against it. +- [x] Diff view supports horizontal scroll on long lines (synced across before/after panels). +- [x] References-loading message reads a plain "로딩 중". +- [x] No in-app back buttons remain on any screen header; system back gesture still exits each screen/subview correctly. +- [x] Pulling down from the top of the repository list or references list triggers a refresh; no manual refresh button remains. +- [x] Build, unit tests, and lint pass. +- [ ] Full device confirmation of the tab UI, swipe gesture, back-button removal, and pull-to-refresh together in one pass. + +## Required checks + +```text +./gradlew assembleDebug +./gradlew testDebugUnitTest +./gradlew lintDebug +``` + +## Evidence + +- `feature/branchdetail/BranchDetailScreen.kt` (new): `TabRow` + `HorizontalPager(pageCount = { 2 })`, page 0 = `CodeBrowserScreen`, page 1 = `HistoryScreen`, both passed through unmodified with their full existing parameter lists. +- `ui/navigation/Route.kt`: `CodeBrowser`/`History` replaced by `BranchDetail(owner, name, refKind, ref)`. `RouteStackSaver.kt`'s `RouteEncoding` updated to match (`TYPE_BRANCH_DETAIL` replaces `TYPE_CODE_BROWSER`/`TYPE_HISTORY`). +- `MainActivity.kt`: `onReferenceSelected` now maps the tapped reference's `ReferenceKind` (branch/tag) to `CodeReferenceKind` and pushes one `Route.BranchDetail`, matching what previously only fed `Route.CodeBrowser`'s `refKind` field — this mapping had been temporarily lost when TASK-024 redirected the tap straight to `Route.History` (which didn't need a ref kind); this task restores it. +- `RouteEncodingTest.kt` updated: `code browser route round-trips` and `history route round-trips` tests replaced by one `branch detail route round-trips including a ref containing a slash` test; the truncated-route and full-back-stack tests updated to match. +- `HistoryScreen.kt`'s `CommitDetailScreen`: each `BoxWithConstraints` branch now wraps `Header(...)` + content in `Column(Modifier.fillMaxSize()) { ... }` instead of leaving them as bare siblings. +- `BranchDetailScreen.kt`: outer `Column` gained `Modifier.statusBarsPadding()`. +- `HistoryScreen.kt`'s `DiffSideLine`: `Modifier.horizontalScroll(horizontalScrollState).padding(...)` with `softWrap = false`, no `fillMaxWidth()`. +- `strings_repositories.xml`: `repositories_references_loading` simplified to a fixed "로딩 중" string (no `%1$s` placeholder); call site in `RepositoryListScreen.kt` updated to match. +- Back buttons removed from `CodeBrowserScreen.BrowserHeader`, `HistoryScreen.Header`, `NotificationsScreen.FilterTabs`, `WorkItemsScreen.ModeTabs`; `WorkItemsScreen.DetailHeader` composable and its 5 call sites removed entirely; `ReferencesContent`'s header back button removed. `history_back`/`notifications_back`/`workitems_back`/`codebrowser_back` string resources removed as now-unused. +- `RepositoryListScreen.kt`: `RepositoryContent`/`ReferencesContent` wrapped in `PullToRefreshBox` (`@OptIn(ExperimentalMaterial3Api::class)`), manual "새로고침" `OutlinedButton`s removed from both. +- `./gradlew compileDebugKotlin`: BUILD SUCCESSFUL (verified after adding the `@OptIn(ExperimentalMaterial3Api::class)` fix for `PullToRefreshBox`). +- `./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks`: BUILD SUCCESSFUL in 3m 22s, 53 actionable tasks executed, all unit tests passing, 0 lint errors. +- Live device: build installed over wireless `adb` (`adb-RFCYA0RD1CZ-ztOzAA._adb-tls-connect._tcp`, Galaxy Z Fold); human product owner asked to confirm tab switching, folder browsing, overlap fix, status-bar padding, diff horizontal scroll, back-button removal, and pull-to-refresh together — result pending as of this commit. + +## Review findings + +None yet — pending Codex/human review.