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 <noreply@anthropic.com>
This commit is contained in:
parent
4b3c11d849
commit
8cf7acd9ac
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)) })
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -22,15 +22,13 @@ val RouteStackSaver: Saver<List<Route>, 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])
|
||||
|
||||
4
app/src/main/res/values/strings_branchdetail.xml
Normal file
4
app/src/main/res/values/strings_branchdetail.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<string name="branchdetail_tab_files">파일</string>
|
||||
<string name="branchdetail_tab_history">히스토리</string>
|
||||
</resources>
|
||||
@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="codebrowser_back">뒤로</string>
|
||||
<string name="codebrowser_ref">%1$s · %2$s</string>
|
||||
<string name="codebrowser_ref_branch">브랜치</string>
|
||||
<string name="codebrowser_ref_tag">태그</string>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
<resources>
|
||||
<string name="history_back">뒤로</string>
|
||||
<string name="history_ref">기준: %1$s</string>
|
||||
<string name="history_empty">커밋 내역이 없습니다.</string>
|
||||
<string name="history_retry">다시 시도</string>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<resources>
|
||||
<string name="repositories_notifications">알림</string>
|
||||
<string name="notifications_title">알림</string>
|
||||
<string name="notifications_back">뒤로</string>
|
||||
<string name="notifications_filter_unread">읽지 않음</string>
|
||||
<string name="notifications_filter_all">전체</string>
|
||||
<string name="notifications_unread_label">[읽지 않음]</string>
|
||||
|
||||
@ -19,8 +19,9 @@
|
||||
<string name="repositories_public">공개</string>
|
||||
<string name="repositories_default_branch">기본 브랜치: %1$s</string>
|
||||
<string name="repositories_no_default_branch">기본 브랜치 없음</string>
|
||||
<string name="repositories_last_commit_at">마지막 커밋: %1$s</string>
|
||||
<string name="repositories_account">%1$s 계정</string>
|
||||
<string name="repositories_references_loading">%1$s 저장소의 브랜치와 태그를 불러오는 중입니다.</string>
|
||||
<string name="repositories_references_loading">로딩 중</string>
|
||||
<string name="repositories_branches_title">브랜치</string>
|
||||
<string name="repositories_branches_empty">브랜치가 없습니다.</string>
|
||||
<string name="repositories_tags_title">태그</string>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
<resources>
|
||||
<string name="workitems_back">뒤로</string>
|
||||
<string name="workitems_tab_issues">이슈</string>
|
||||
<string name="workitems_tab_pull_requests">Pull Request</string>
|
||||
<string name="workitems_empty">표시할 항목이 없습니다.</string>
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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.
|
||||
|
||||
78
tasks/025-branch-detail-tabs.md
Normal file
78
tasks/025-branch-detail-tabs.md
Normal file
@ -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.
|
||||
Loading…
x
Reference in New Issue
Block a user