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:
최민영 2026-08-27 01:39:17 +09:00
parent 4b3c11d849
commit 8cf7acd9ac
18 changed files with 334 additions and 134 deletions

View File

@ -16,15 +16,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryRef 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.CodeBrowserTarget
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel
import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind 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.HistoryTarget
import com.ubisam.ubgitea.feature.history.HistoryViewModel import com.ubisam.ubgitea.feature.history.HistoryViewModel
import com.ubisam.ubgitea.feature.notifications.NotificationsScreen import com.ubisam.ubgitea.feature.notifications.NotificationsScreen
import com.ubisam.ubgitea.feature.notifications.NotificationsViewModel 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.RepositoryListScreen
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen
@ -72,16 +72,12 @@ class MainActivity : ComponentActivity() {
LaunchedEffect(currentRoute) { LaunchedEffect(currentRoute) {
when (val route = currentRoute) { when (val route = currentRoute) {
Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation. Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation.
is Route.CodeBrowser -> codeBrowserViewModel.open( is Route.BranchDetail -> {
CodeBrowserTarget( val repository = RepositoryCoordinates.of(route.owner, route.name)
repository = RepositoryCoordinates.of(route.owner, route.name), val ref = RepositoryRef.of(route.ref)
ref = RepositoryRef.of(route.ref), codeBrowserViewModel.open(CodeBrowserTarget(repository, ref, CodeReferenceKind.valueOf(route.refKind)))
kind = CodeReferenceKind.valueOf(route.refKind), historyViewModel.open(HistoryTarget(repository, ref))
), }
)
is Route.History -> historyViewModel.open(
HistoryTarget(RepositoryCoordinates.of(route.owner, route.name), RepositoryRef.of(route.ref)),
)
is Route.WorkItems -> workItemsViewModel.open( is Route.WorkItems -> workItemsViewModel.open(
WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name)), WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name)),
) )
@ -133,39 +129,35 @@ class MainActivity : ComponentActivity() {
}, },
onAuthenticationExpired = ::authenticationExpired, onAuthenticationExpired = ::authenticationExpired,
) )
is Route.History -> HistoryScreen( is Route.BranchDetail -> BranchDetailScreen(
state = historyState, 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, onCommitSelected = historyViewModel::openCommit,
onNextPage = historyViewModel::nextPage, onHistoryNextPage = historyViewModel::nextPage,
onPreviousPage = historyViewModel::previousPage, onHistoryPreviousPage = historyViewModel::previousPage,
onRetryList = historyViewModel::retryList, onRetryHistoryList = historyViewModel::retryList,
onRetryDetail = historyViewModel::retryDetail, onRetryHistoryDetail = historyViewModel::retryDetail,
onRetryPatch = historyViewModel::retryPatch, onRetryPatch = historyViewModel::retryPatch,
onFileSelected = historyViewModel::openFileDiff, onFileSelected = historyViewModel::openFileDiff,
onClearFileSelection = historyViewModel::clearFileSelection, onClearFileSelection = historyViewModel::clearFileSelection,
onBackToList = historyViewModel::backToList, onHistoryBackToList = historyViewModel::backToList,
onBack = { onHistoryBack = {
codeBrowserViewModel.close()
historyViewModel.close() historyViewModel.close()
pop() pop()
}, },
onAuthenticationExpired = ::authenticationExpired, 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( Route.Repositories -> RepositoryListScreen(
state = state, state = state,
onSignIn = viewModel::signIn, onSignIn = viewModel::signIn,
@ -175,7 +167,15 @@ class MainActivity : ComponentActivity() {
onRepositorySelected = viewModel::selectRepository, onRepositorySelected = viewModel::selectRepository,
onReferenceSelected = { repository, name, kind -> onReferenceSelected = { repository, name, kind ->
viewModel.selectReference(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, onReferencesRefresh = viewModel::refreshReferences,
onBackToRepositories = viewModel::backToRepositories, onBackToRepositories = viewModel::backToRepositories,

View File

@ -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,
)
}
}
}
}

View File

@ -54,11 +54,11 @@ fun CodeBrowserScreen(
when (state) { when (state) {
CodeBrowserUiState.Idle -> Unit CodeBrowserUiState.Idle -> Unit
is CodeBrowserUiState.Loading -> { is CodeBrowserUiState.Loading -> {
BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected, onBack, onViewHistory) BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected, onViewHistory)
CenterMessage { CircularProgressIndicator() } CenterMessage { CircularProgressIndicator() }
} }
is CodeBrowserUiState.Directory -> { is CodeBrowserUiState.Directory -> {
BrowserHeader(state.target, state.path, false, onBreadcrumbSelected, onBack, onViewHistory) BrowserHeader(state.target, state.path, false, onBreadcrumbSelected, onViewHistory)
if (state.entries.isEmpty()) { if (state.entries.isEmpty()) {
CenterMessage { Text(stringResource(R.string.codebrowser_directory_empty)) } CenterMessage { Text(stringResource(R.string.codebrowser_directory_empty)) }
} else { } else {
@ -70,11 +70,11 @@ fun CodeBrowserScreen(
} }
} }
is CodeBrowserUiState.File -> { is CodeBrowserUiState.File -> {
BrowserHeader(state.target, state.path, true, onBreadcrumbSelected, onBack, onViewHistory) BrowserHeader(state.target, state.path, true, onBreadcrumbSelected, onViewHistory)
FileContent(state.content) FileContent(state.content)
} }
is CodeBrowserUiState.UnsupportedEntry -> { is CodeBrowserUiState.UnsupportedEntry -> {
BrowserHeader(state.target, state.path, true, onBreadcrumbSelected, onBack, onViewHistory) BrowserHeader(state.target, state.path, true, onBreadcrumbSelected, onViewHistory)
CenterMessage { CenterMessage {
Text( Text(
stringResource( stringResource(
@ -85,7 +85,7 @@ fun CodeBrowserScreen(
} }
} }
is CodeBrowserUiState.Error -> { is CodeBrowserUiState.Error -> {
BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected, onBack, onViewHistory) BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected, onViewHistory)
CenterMessage { CenterMessage {
Text(stringResource(state.type.messageResource)) Text(stringResource(state.type.messageResource))
if (state.type == CodeBrowserError.AUTHENTICATION_EXPIRED) { if (state.type == CodeBrowserError.AUTHENTICATION_EXPIRED) {
@ -108,13 +108,11 @@ private fun BrowserHeader(
path: RepositoryPath, path: RepositoryPath,
leafIsFile: Boolean, leafIsFile: Boolean,
onBreadcrumbSelected: (RepositoryPath) -> Unit, onBreadcrumbSelected: (RepositoryPath) -> Unit,
onBack: () -> Unit,
onViewHistory: (CodeBrowserTarget) -> Unit, onViewHistory: (CodeBrowserTarget) -> Unit,
) { ) {
Column(Modifier.fillMaxWidth().padding(16.dp)) { Column(Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(onClick = onBack) { Text(stringResource(R.string.codebrowser_back)) } Column(Modifier.weight(1f)) {
Column(Modifier.padding(start = 12.dp).weight(1f)) {
Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge) Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge)
val refKind = when (target.kind) { val refKind = when (target.kind) {
CodeReferenceKind.BRANCH -> stringResource(R.string.codebrowser_ref_branch) CodeReferenceKind.BRANCH -> stringResource(R.string.codebrowser_ref_branch)

View File

@ -73,12 +73,12 @@ fun HistoryScreen(
HistoryUiState.Idle -> Unit HistoryUiState.Idle -> Unit
is HistoryUiState.Loading -> { is HistoryUiState.Loading -> {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
Header(state.target, onBack) Header(state.target)
CenterMessage { CircularProgressIndicator() } CenterMessage { CircularProgressIndicator() }
} }
is HistoryUiState.CommitList -> { is HistoryUiState.CommitList -> {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
Header(state.target, onBack) Header(state.target)
if (state.commits.isEmpty()) { if (state.commits.isEmpty()) {
CenterMessage { Text(stringResource(R.string.history_empty)) } CenterMessage { Text(stringResource(R.string.history_empty)) }
} else { } else {
@ -92,7 +92,7 @@ fun HistoryScreen(
} }
is HistoryUiState.CommitDetailLoading -> { is HistoryUiState.CommitDetailLoading -> {
BackHandler(onBack = onBackToList) BackHandler(onBack = onBackToList)
Header(state.target, onBackToList) Header(state.target)
CenterMessage { CircularProgressIndicator() } CenterMessage { CircularProgressIndicator() }
} }
is HistoryUiState.CommitDetailContent -> { is HistoryUiState.CommitDetailContent -> {
@ -100,7 +100,7 @@ fun HistoryScreen(
} }
is HistoryUiState.Error -> { is HistoryUiState.Error -> {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
Header(state.target, onBack) Header(state.target)
CenterMessage { CenterMessage {
Text(stringResource(state.type.messageResource)) Text(stringResource(state.type.messageResource))
if (state.type == HistoryError.AUTHENTICATION_EXPIRED) { if (state.type == HistoryError.AUTHENTICATION_EXPIRED) {
@ -118,15 +118,10 @@ fun HistoryScreen(
} }
@Composable @Composable
private fun Header(target: HistoryTarget, onBack: () -> Unit) { private fun Header(target: HistoryTarget) {
Column(Modifier.fillMaxWidth().padding(16.dp)) { Column(Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge)
OutlinedButton(onClick = onBack) { Text(stringResource(R.string.history_back)) } Text(stringResource(R.string.history_ref, target.ref.value), style = MaterialTheme.typography.bodyMedium)
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)
}
}
} }
HorizontalDivider() HorizontalDivider()
} }
@ -166,7 +161,7 @@ private fun CommitDetailScreen(
BackHandler(onBack = onBackToList) BackHandler(onBack = onBackToList)
Row(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Header(state.target, onBackToList) Header(state.target)
CommitDetailView(state, onRetryPatch, onFileSelected) CommitDetailView(state, onRetryPatch, onFileSelected)
} }
VerticalDivider() VerticalDivider()
@ -181,12 +176,16 @@ private fun CommitDetailScreen(
} }
} else if (state.selectedFile != null) { } else if (state.selectedFile != null) {
BackHandler(onBack = onClearFileSelection) BackHandler(onBack = onClearFileSelection)
Header(state.target, onClearFileSelection) Column(Modifier.fillMaxSize()) {
FileDiffView(state.selectedFile) Header(state.target)
FileDiffView(state.selectedFile)
}
} else { } else {
BackHandler(onBack = onBackToList) BackHandler(onBack = onBackToList)
Header(state.target, onBackToList) Column(Modifier.fillMaxSize()) {
CommitDetailView(state, onRetryPatch, onFileSelected) Header(state.target)
CommitDetailView(state, onRetryPatch, onFileSelected)
}
} }
} }
} }
@ -324,7 +323,8 @@ private fun DiffSideLine(line: DiffLine?, isGap: Boolean, horizontalScrollState:
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = color, 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),
) )
} }

View File

@ -50,11 +50,11 @@ fun NotificationsScreen(
when (state) { when (state) {
NotificationsUiState.Idle -> Unit NotificationsUiState.Idle -> Unit
NotificationsUiState.Loading -> { NotificationsUiState.Loading -> {
FilterTabs(unreadOnly = true, onShowUnreadOnly, onShowAll, onBack) FilterTabs(unreadOnly = true, onShowUnreadOnly, onShowAll)
CenterMessage { CircularProgressIndicator() } CenterMessage { CircularProgressIndicator() }
} }
is NotificationsUiState.Content -> { is NotificationsUiState.Content -> {
FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll, onBack) FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll)
LazyColumn(Modifier.weight(1f)) { LazyColumn(Modifier.weight(1f)) {
items(state.items, key = NotificationSummary::id) { notification -> items(state.items, key = NotificationSummary::id) { notification ->
NotificationRow(notification) { onNotificationSelected(notification) } NotificationRow(notification) { onNotificationSelected(notification) }
@ -63,11 +63,11 @@ fun NotificationsScreen(
PageFooter(state.page, state.page > 1, state.hasNextPage, onPreviousPage, onNextPage) PageFooter(state.page, state.page > 1, state.hasNextPage, onPreviousPage, onNextPage)
} }
is NotificationsUiState.Empty -> { is NotificationsUiState.Empty -> {
FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll, onBack) FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll)
CenterMessage { Text(stringResource(R.string.notifications_empty)) } CenterMessage { Text(stringResource(R.string.notifications_empty)) }
} }
is NotificationsUiState.Error -> { is NotificationsUiState.Error -> {
FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll, onBack) FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll)
ErrorMessage(state.type, onRetry, onAuthenticationExpired) ErrorMessage(state.type, onRetry, onAuthenticationExpired)
} }
} }
@ -76,10 +76,7 @@ fun NotificationsScreen(
} }
@Composable @Composable
private fun FilterTabs(unreadOnly: Boolean, onShowUnreadOnly: () -> Unit, onShowAll: () -> Unit, onBack: () -> Unit) { private fun FilterTabs(unreadOnly: Boolean, onShowUnreadOnly: () -> Unit, onShowAll: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
OutlinedButton(onClick = onBack) { Text(stringResource(R.string.notifications_back)) }
}
TabRow(selectedTabIndex = if (unreadOnly) 0 else 1) { TabRow(selectedTabIndex = if (unreadOnly) 0 else 1) {
Tab(selected = unreadOnly, onClick = onShowUnreadOnly, text = { Text(stringResource(R.string.notifications_filter_unread)) }) 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)) }) Tab(selected = !unreadOnly, onClick = onShowAll, text = { Text(stringResource(R.string.notifications_filter_all)) })

View File

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable 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.foundation.lazy.items
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
@ -28,6 +31,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
@ -77,7 +81,10 @@ fun RepositoryListScreen(
stringResource(R.string.repositories_loading), stringResource(R.string.repositories_loading),
Modifier.padding(padding), 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) ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
MessageState( MessageState(
if (state.mode == RepositoryListMode.FAVORITES) { if (state.mode == RepositoryListMode.FAVORITES) {
@ -122,7 +129,7 @@ fun RepositoryListScreen(
is RepositoryListUiState.ReferencesLoading -> { is RepositoryListUiState.ReferencesLoading -> {
BackHandler(onBack = onBackToRepositories) BackHandler(onBack = onBackToRepositories)
LoadingState( LoadingState(
stringResource(R.string.repositories_references_loading, state.repository.name), stringResource(R.string.repositories_references_loading),
Modifier.padding(padding), Modifier.padding(padding),
) )
} }
@ -132,7 +139,6 @@ fun RepositoryListScreen(
state, state,
onReferenceSelected, onReferenceSelected,
onReferencesRefresh, onReferencesRefresh,
onBackToRepositories,
onLogout, onLogout,
onViewWorkItems, onViewWorkItems,
Modifier.padding(padding), Modifier.padding(padding),
@ -187,6 +193,7 @@ private fun MessageState(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun RepositoryContent( private fun RepositoryContent(
state: RepositoryListUiState.Content, state: RepositoryListUiState.Content,
@ -202,7 +209,14 @@ private fun RepositoryContent(
onViewNotifications: () -> Unit, onViewNotifications: () -> Unit,
modifier: Modifier = Modifier, 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 { item {
Row( Row(
Modifier.fillMaxWidth().padding(16.dp), Modifier.fillMaxWidth().padding(16.dp),
@ -214,9 +228,6 @@ private fun RepositoryContent(
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
OutlinedButton(onClick = onViewNotifications) { Text(stringResource(R.string.repositories_notifications)) } 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)) { OutlinedButton(onClick = onLogout, modifier = Modifier.padding(start = 8.dp)) {
Text(stringResource(R.string.repositories_logout)) Text(stringResource(R.string.repositories_logout))
} }
@ -246,6 +257,10 @@ private fun RepositoryContent(
} ?: stringResource(R.string.repositories_no_default_branch), } ?: stringResource(R.string.repositories_no_default_branch),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Text(
stringResource(R.string.repositories_last_commit_at, formatInstant(repository.updatedAtEpochMillis)),
style = MaterialTheme.typography.bodySmall,
)
LastPushedBranchRow(repository, onRetryLastPushedBranch) LastPushedBranchRow(repository, onRetryLastPushedBranch)
} }
HorizontalDivider() HorizontalDivider()
@ -266,11 +281,37 @@ private fun RepositoryContent(
} }
} }
} }
}
} }
internal fun repositoryLazyListKey(coordinates: RepositoryCoordinates): String = internal fun repositoryLazyListKey(coordinates: RepositoryCoordinates): String =
"${coordinates.owner}/${coordinates.name}" "${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 @Composable
private fun OrganizationFilterRow( private fun OrganizationFilterRow(
selected: String?, selected: String?,
@ -339,24 +380,24 @@ private fun LastPushedBranchRow(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ReferencesContent( private fun ReferencesContent(
state: RepositoryListUiState.ReferencesContent, state: RepositoryListUiState.ReferencesContent,
onReferenceSelected: (RepositoryRowUiModel, String, ReferenceKind) -> Unit, onReferenceSelected: (RepositoryRowUiModel, String, ReferenceKind) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onBack: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onViewWorkItems: (RepositoryRowUiModel) -> Unit, onViewWorkItems: (RepositoryRowUiModel) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn(modifier = modifier.fillMaxSize()) { PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
item { item {
Row( Row(
Modifier.fillMaxWidth().padding(16.dp), Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
OutlinedButton(onClick = onBack) { Text(stringResource(R.string.repositories_back)) } Column(Modifier.weight(1f)) {
Column(Modifier.padding(start = 12.dp).weight(1f)) {
Text("${state.repository.owner}/${state.repository.name}", style = MaterialTheme.typography.titleLarge) Text("${state.repository.owner}/${state.repository.name}", style = MaterialTheme.typography.titleLarge)
Text(stringResource(R.string.repositories_account, state.accountName)) Text(stringResource(R.string.repositories_account, state.accountName))
} }
@ -365,7 +406,6 @@ private fun ReferencesContent(
} }
item { item {
Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { 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) }) { OutlinedButton(onClick = { onViewWorkItems(state.repository) }) {
Text(stringResource(R.string.repositories_work_items)) Text(stringResource(R.string.repositories_work_items))
} }
@ -398,6 +438,7 @@ private fun ReferencesContent(
ReferenceRow(reference) { name, kind -> onReferenceSelected(state.repository, name, kind) } ReferenceRow(reference) { name, kind -> onReferenceSelected(state.repository, name, kind) }
} }
} }
}
} }
@Composable @Composable

View File

@ -58,12 +58,12 @@ fun WorkItemsScreen(
WorkItemsUiState.Idle -> Unit WorkItemsUiState.Idle -> Unit
is WorkItemsUiState.Loading -> { is WorkItemsUiState.Loading -> {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests, onBack) ModeTabs(state.tab, onShowIssues, onShowPullRequests)
CenterMessage { CircularProgressIndicator() } CenterMessage { CircularProgressIndicator() }
} }
is WorkItemsUiState.IssueList -> { is WorkItemsUiState.IssueList -> {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
ModeTabs(WorkItemsTab.ISSUES, onShowIssues, onShowPullRequests, onBack) ModeTabs(WorkItemsTab.ISSUES, onShowIssues, onShowPullRequests)
LazyColumn(Modifier.weight(1f)) { LazyColumn(Modifier.weight(1f)) {
items(state.items, key = IssueSummary::number) { issue -> items(state.items, key = IssueSummary::number) { issue ->
IssueRow(issue) { onIssueSelected(issue) } IssueRow(issue) { onIssueSelected(issue) }
@ -73,7 +73,7 @@ fun WorkItemsScreen(
} }
is WorkItemsUiState.PullRequestList -> { is WorkItemsUiState.PullRequestList -> {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
ModeTabs(WorkItemsTab.PULL_REQUESTS, onShowIssues, onShowPullRequests, onBack) ModeTabs(WorkItemsTab.PULL_REQUESTS, onShowIssues, onShowPullRequests)
LazyColumn(Modifier.weight(1f)) { LazyColumn(Modifier.weight(1f)) {
items(state.items, key = PullRequestSummary::number) { pr -> items(state.items, key = PullRequestSummary::number) { pr ->
PullRequestRow(pr) { onPullRequestSelected(pr) } PullRequestRow(pr) { onPullRequestSelected(pr) }
@ -83,37 +83,32 @@ fun WorkItemsScreen(
} }
is WorkItemsUiState.Empty -> { is WorkItemsUiState.Empty -> {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests, onBack) ModeTabs(state.tab, onShowIssues, onShowPullRequests)
CenterMessage { Text(stringResource(R.string.workitems_empty)) } CenterMessage { Text(stringResource(R.string.workitems_empty)) }
} }
is WorkItemsUiState.ListError -> { is WorkItemsUiState.ListError -> {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests, onBack) ModeTabs(state.tab, onShowIssues, onShowPullRequests)
ErrorMessage(state.type, onRetryList, onAuthenticationExpired) ErrorMessage(state.type, onRetryList, onAuthenticationExpired)
} }
is WorkItemsUiState.IssueDetailLoading -> { is WorkItemsUiState.IssueDetailLoading -> {
BackHandler(onBack = onBackToList) BackHandler(onBack = onBackToList)
DetailHeader(onBackToList)
CenterMessage { CircularProgressIndicator() } CenterMessage { CircularProgressIndicator() }
} }
is WorkItemsUiState.IssueDetailContent -> { is WorkItemsUiState.IssueDetailContent -> {
BackHandler(onBack = onBackToList) BackHandler(onBack = onBackToList)
DetailHeader(onBackToList)
IssueDetailView(state) IssueDetailView(state)
} }
is WorkItemsUiState.PullRequestDetailLoading -> { is WorkItemsUiState.PullRequestDetailLoading -> {
BackHandler(onBack = onBackToList) BackHandler(onBack = onBackToList)
DetailHeader(onBackToList)
CenterMessage { CircularProgressIndicator() } CenterMessage { CircularProgressIndicator() }
} }
is WorkItemsUiState.PullRequestDetailContent -> { is WorkItemsUiState.PullRequestDetailContent -> {
BackHandler(onBack = onBackToList) BackHandler(onBack = onBackToList)
DetailHeader(onBackToList)
PullRequestDetailView(state) PullRequestDetailView(state)
} }
is WorkItemsUiState.DetailError -> { is WorkItemsUiState.DetailError -> {
BackHandler(onBack = onBackToList) BackHandler(onBack = onBackToList)
DetailHeader(onBackToList)
ErrorMessage(state.type, onRetryDetail, onAuthenticationExpired) ErrorMessage(state.type, onRetryDetail, onAuthenticationExpired)
} }
} }
@ -122,10 +117,7 @@ fun WorkItemsScreen(
} }
@Composable @Composable
private fun ModeTabs(tab: WorkItemsTab, onShowIssues: () -> Unit, onShowPullRequests: () -> Unit, onBack: () -> Unit) { private fun ModeTabs(tab: WorkItemsTab, onShowIssues: () -> Unit, onShowPullRequests: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
OutlinedButton(onClick = onBack) { Text(stringResource(R.string.workitems_back)) }
}
TabRow(selectedTabIndex = if (tab == WorkItemsTab.PULL_REQUESTS) 1 else 0) { TabRow(selectedTabIndex = if (tab == WorkItemsTab.PULL_REQUESTS) 1 else 0) {
Tab( Tab(
selected = tab == WorkItemsTab.ISSUES, 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 @Composable
private fun IssueRow(issue: IssueSummary, onSelected: () -> Unit) { private fun IssueRow(issue: IssueSummary, onSelected: () -> Unit) {
Column(Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 12.dp)) { Column(Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 12.dp)) {

View File

@ -8,8 +8,12 @@ package com.ubisam.ubgitea.ui.navigation
*/ */
sealed interface Route { sealed interface Route {
data object Repositories : 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 class WorkItems(val owner: String, val name: String) : Route
data object Notifications : Route data object Notifications : Route
} }

View File

@ -22,15 +22,13 @@ val RouteStackSaver: Saver<List<Route>, Any> = listSaver(
internal object RouteEncoding { internal object RouteEncoding {
private const val FIELD_SEPARATOR = '' private const val FIELD_SEPARATOR = ''
private const val TYPE_REPOSITORIES = "repositories" private const val TYPE_REPOSITORIES = "repositories"
private const val TYPE_CODE_BROWSER = "codeBrowser" private const val TYPE_BRANCH_DETAIL = "branchDetail"
private const val TYPE_HISTORY = "history"
private const val TYPE_WORK_ITEMS = "workItems" private const val TYPE_WORK_ITEMS = "workItems"
private const val TYPE_NOTIFICATIONS = "notifications" private const val TYPE_NOTIFICATIONS = "notifications"
fun encode(route: Route): String = when (route) { fun encode(route: Route): String = when (route) {
Route.Repositories -> TYPE_REPOSITORIES Route.Repositories -> TYPE_REPOSITORIES
is Route.CodeBrowser -> join(TYPE_CODE_BROWSER, route.owner, route.name, route.refKind, route.ref) is Route.BranchDetail -> join(TYPE_BRANCH_DETAIL, route.owner, route.name, route.refKind, route.ref)
is Route.History -> join(TYPE_HISTORY, route.owner, route.name, route.ref)
is Route.WorkItems -> join(TYPE_WORK_ITEMS, route.owner, route.name) is Route.WorkItems -> join(TYPE_WORK_ITEMS, route.owner, route.name)
Route.Notifications -> TYPE_NOTIFICATIONS Route.Notifications -> TYPE_NOTIFICATIONS
} }
@ -40,11 +38,8 @@ internal object RouteEncoding {
fun decodeOrNull(encoded: String): Route? { fun decodeOrNull(encoded: String): Route? {
val parts = encoded.split(FIELD_SEPARATOR) val parts = encoded.split(FIELD_SEPARATOR)
return when (parts.first()) { return when (parts.first()) {
TYPE_CODE_BROWSER -> parts.takeIf { it.size == 5 }?.let { TYPE_BRANCH_DETAIL -> parts.takeIf { it.size == 5 }?.let {
Route.CodeBrowser(owner = it[1], name = it[2], refKind = it[3], ref = it[4]) Route.BranchDetail(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_WORK_ITEMS -> parts.takeIf { it.size == 3 }?.let { TYPE_WORK_ITEMS -> parts.takeIf { it.size == 3 }?.let {
Route.WorkItems(owner = it[1], name = it[2]) Route.WorkItems(owner = it[1], name = it[2])

View File

@ -0,0 +1,4 @@
<resources>
<string name="branchdetail_tab_files">파일</string>
<string name="branchdetail_tab_history">히스토리</string>
</resources>

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="codebrowser_back">뒤로</string>
<string name="codebrowser_ref">%1$s · %2$s</string> <string name="codebrowser_ref">%1$s · %2$s</string>
<string name="codebrowser_ref_branch">브랜치</string> <string name="codebrowser_ref_branch">브랜치</string>
<string name="codebrowser_ref_tag">태그</string> <string name="codebrowser_ref_tag">태그</string>

View File

@ -1,5 +1,4 @@
<resources> <resources>
<string name="history_back">뒤로</string>
<string name="history_ref">기준: %1$s</string> <string name="history_ref">기준: %1$s</string>
<string name="history_empty">커밋 내역이 없습니다.</string> <string name="history_empty">커밋 내역이 없습니다.</string>
<string name="history_retry">다시 시도</string> <string name="history_retry">다시 시도</string>

View File

@ -1,7 +1,6 @@
<resources> <resources>
<string name="repositories_notifications">알림</string> <string name="repositories_notifications">알림</string>
<string name="notifications_title">알림</string> <string name="notifications_title">알림</string>
<string name="notifications_back">뒤로</string>
<string name="notifications_filter_unread">읽지 않음</string> <string name="notifications_filter_unread">읽지 않음</string>
<string name="notifications_filter_all">전체</string> <string name="notifications_filter_all">전체</string>
<string name="notifications_unread_label">[읽지 않음]</string> <string name="notifications_unread_label">[읽지 않음]</string>

View File

@ -19,8 +19,9 @@
<string name="repositories_public">공개</string> <string name="repositories_public">공개</string>
<string name="repositories_default_branch">기본 브랜치: %1$s</string> <string name="repositories_default_branch">기본 브랜치: %1$s</string>
<string name="repositories_no_default_branch">기본 브랜치 없음</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_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_title">브랜치</string>
<string name="repositories_branches_empty">브랜치가 없습니다.</string> <string name="repositories_branches_empty">브랜치가 없습니다.</string>
<string name="repositories_tags_title">태그</string> <string name="repositories_tags_title">태그</string>

View File

@ -1,5 +1,4 @@
<resources> <resources>
<string name="workitems_back">뒤로</string>
<string name="workitems_tab_issues">이슈</string> <string name="workitems_tab_issues">이슈</string>
<string name="workitems_tab_pull_requests">Pull Request</string> <string name="workitems_tab_pull_requests">Pull Request</string>
<string name="workitems_empty">표시할 항목이 없습니다.</string> <string name="workitems_empty">표시할 항목이 없습니다.</string>

View File

@ -11,14 +11,8 @@ class RouteEncodingTest {
} }
@Test @Test
fun `code browser route round-trips including a ref containing a slash`() { fun `branch detail 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") val route = Route.BranchDetail(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")
assertEquals(route, RouteEncoding.decode(RouteEncoding.encode(route))) assertEquals(route, RouteEncoding.decode(RouteEncoding.encode(route)))
} }
@ -35,17 +29,15 @@ class RouteEncodingTest {
@Test @Test
fun `truncated persisted routes fall back instead of indexing missing fields`() { fun `truncated persisted routes fall back instead of indexing missing fields`() {
assertEquals(Route.Repositories, RouteEncoding.decode("codeBrowser\u001fowner")) assertEquals(Route.Repositories, RouteEncoding.decode("branchDetail" + '' + "owner"))
assertEquals(Route.Repositories, RouteEncoding.decode("history\u001fowner\u001frepo")) assertEquals(Route.Repositories, RouteEncoding.decode("workItems" + '' + "owner"))
assertEquals(Route.Repositories, RouteEncoding.decode("workItems\u001fowner"))
} }
@Test @Test
fun `a full back stack round-trips through the saver in order`() { fun `a full back stack round-trips through the saver in order`() {
val stack = listOf( val stack = listOf(
Route.Repositories, Route.Repositories,
Route.CodeBrowser("owner", "repo", "BRANCH", "main"), Route.BranchDetail("owner", "repo", "BRANCH", "main"),
Route.History("owner", "repo", "main"),
) )
val scope = SaverScope { true } val scope = SaverScope { true }

View File

@ -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. - 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 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. - 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.

View 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.