TASK-050..054: tabs, Home, Work, code viewer, C++ pairs, fold layouts
Four tabs in an app shell that becomes a rail from 600dp, a brighter sky-blue palette with the real UbiSam logo, a Home tab built on Gitea's activity feeds, a Work tab that searches issues and pull requests across repositories, a full-screen code viewer with search, outline and Visual Studio colors, C/C++ header/source pairing, and two-pane layouts for wide windows. The five tasks were built and checked together, so they are one commit: MainActivity, the routes and the shared components belong to all of them. 409 unit tests pass, lint reports 0 errors. Verified on an emulator up to the sign-in screen only; no physical device was available. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
105e181bd2
commit
d1a45abc16
@ -7,39 +7,54 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
||||
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectType
|
||||
import com.ubisam.ubgitea.feature.branchdetail.BranchDetailScreen
|
||||
import com.ubisam.ubgitea.feature.branchdetail.isReadingFullScreen
|
||||
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.codebrowser.CodeViewerPreferences
|
||||
import com.ubisam.ubgitea.feature.history.HistoryTarget
|
||||
import com.ubisam.ubgitea.feature.history.HistoryViewModel
|
||||
import com.ubisam.ubgitea.feature.home.HomeDestination
|
||||
import com.ubisam.ubgitea.feature.home.HomeScreen
|
||||
import com.ubisam.ubgitea.feature.home.HomeViewModel
|
||||
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.RepositoryListUiState
|
||||
import com.ubisam.ubgitea.feature.repositories.accountNameOrNull
|
||||
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
|
||||
import com.ubisam.ubgitea.feature.settings.SettingsScreen
|
||||
import com.ubisam.ubgitea.feature.settings.SettingsViewModel
|
||||
import com.ubisam.ubgitea.feature.work.WorkScreen
|
||||
import com.ubisam.ubgitea.feature.work.WorkViewModel
|
||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen
|
||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget
|
||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel
|
||||
import com.ubisam.ubgitea.ui.navigation.Route
|
||||
import com.ubisam.ubgitea.ui.navigation.RouteStackSaver
|
||||
import com.ubisam.ubgitea.ui.shell.AppShell
|
||||
import com.ubisam.ubgitea.ui.shell.AppTab
|
||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@ -61,6 +76,12 @@ class MainActivity : ComponentActivity() {
|
||||
private val settingsViewModel: SettingsViewModel by viewModels {
|
||||
SettingsViewModel.Factory(application)
|
||||
}
|
||||
private val homeViewModel: HomeViewModel by viewModels {
|
||||
HomeViewModel.Factory(application)
|
||||
}
|
||||
private val workViewModel: WorkViewModel by viewModels {
|
||||
WorkViewModel.Factory(application)
|
||||
}
|
||||
|
||||
// TASK-036: no-op if already granted or already permanently denied -- the OS itself decides
|
||||
// whether to actually show a prompt, this call is safe to make on every launch. Must be
|
||||
@ -87,15 +108,29 @@ class MainActivity : ComponentActivity() {
|
||||
val historyState by historyViewModel.state.collectAsState()
|
||||
val workItemsState by workItemsViewModel.state.collectAsState()
|
||||
val notificationsState by notificationsViewModel.state.collectAsState()
|
||||
val unreadNotifications by notificationsViewModel.unreadCount.collectAsState()
|
||||
val settingsState by settingsViewModel.state.collectAsState()
|
||||
val homeState by homeViewModel.state.collectAsState()
|
||||
val workState by workViewModel.state.collectAsState()
|
||||
|
||||
var routeStack by rememberSaveable(stateSaver = RouteStackSaver) {
|
||||
mutableStateOf(listOf<Route>(Route.Repositories))
|
||||
mutableStateOf(listOf<Route>(Route.Home))
|
||||
}
|
||||
fun push(route: Route) { routeStack = routeStack + route }
|
||||
fun popToRepositories() { routeStack = listOf(Route.Repositories) }
|
||||
fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Repositories) } }
|
||||
fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Home) } }
|
||||
fun selectTab(tab: AppTab) { routeStack = listOf(tab.route) }
|
||||
val currentRoute = routeStack.last()
|
||||
val currentTab = routeStack.firstNotNullOfOrNull { it.tab } ?: AppTab.HOME
|
||||
|
||||
// The repository list's ViewModel owns sign-in, so its state says whether there is a
|
||||
// session. While it is merely loading, whatever was true before still is.
|
||||
var sessionReady by rememberSaveable { mutableStateOf(false) }
|
||||
var accountName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(state) {
|
||||
state.sessionReadyOrNull()?.let { sessionReady = it }
|
||||
if (state is RepositoryListUiState.SignedOut) accountName = null
|
||||
state.accountNameOrNull()?.let { accountName = it }
|
||||
}
|
||||
|
||||
// TASK-036: tapping a favorite-push notification. Pushed on top of whatever's
|
||||
// currently showing rather than resetting the stack -- consistent with how every other
|
||||
@ -107,139 +142,258 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentRoute) {
|
||||
LaunchedEffect(currentRoute, sessionReady) {
|
||||
if (!sessionReady) return@LaunchedEffect
|
||||
if (currentRoute.isTab) notificationsViewModel.refreshUnreadCount()
|
||||
when (val route = currentRoute) {
|
||||
Route.Home -> homeViewModel.open()
|
||||
Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation.
|
||||
Route.Work -> workViewModel.open()
|
||||
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)),
|
||||
)
|
||||
is Route.WorkItems -> {
|
||||
val target = WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name))
|
||||
if (route.number != null) {
|
||||
workItemsViewModel.openItem(target, route.number, route.isPullRequest)
|
||||
} else {
|
||||
workItemsViewModel.open(target)
|
||||
}
|
||||
}
|
||||
Route.Notifications -> notificationsViewModel.open()
|
||||
Route.Settings -> Unit // SettingsViewModel reads its (synchronous) store at construction.
|
||||
}
|
||||
}
|
||||
|
||||
fun authenticationExpired() {
|
||||
fun closeFeatures() {
|
||||
codeBrowserViewModel.close()
|
||||
historyViewModel.close()
|
||||
workItemsViewModel.close()
|
||||
notificationsViewModel.close()
|
||||
popToRepositories()
|
||||
notificationsViewModel.clearUnreadCount()
|
||||
homeViewModel.close()
|
||||
workViewModel.close()
|
||||
}
|
||||
fun signOut() {
|
||||
closeFeatures()
|
||||
selectTab(AppTab.HOME)
|
||||
viewModel.logout()
|
||||
}
|
||||
fun leaveBranchDetail() {
|
||||
codeBrowserViewModel.close()
|
||||
historyViewModel.close()
|
||||
pop()
|
||||
}
|
||||
|
||||
val instanceHost = UBGiteaOAuthDefaults.config.baseUri.host.orEmpty()
|
||||
val codeViewerPreferences = CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
|
||||
val showNavigation = sessionReady && when (currentRoute) {
|
||||
is Route.BranchDetail -> !isReadingFullScreen(codeBrowserState, historyState)
|
||||
Route.Settings -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
UBGiteaTheme {
|
||||
when (currentRoute) {
|
||||
Route.Settings -> SettingsScreen(
|
||||
state = settingsState,
|
||||
onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled,
|
||||
onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled,
|
||||
onBack = { pop() },
|
||||
)
|
||||
Route.Notifications -> NotificationsScreen(
|
||||
state = notificationsState,
|
||||
onShowUnreadOnly = notificationsViewModel::showUnreadOnly,
|
||||
onShowAll = notificationsViewModel::showAll,
|
||||
onNotificationSelected = { notification ->
|
||||
push(Route.WorkItems(notification.repositoryOwner, notification.repositoryName))
|
||||
},
|
||||
onNextPage = notificationsViewModel::nextPage,
|
||||
onPreviousPage = notificationsViewModel::previousPage,
|
||||
onRetry = notificationsViewModel::retry,
|
||||
onBack = {
|
||||
notificationsViewModel.close()
|
||||
pop()
|
||||
},
|
||||
onAuthenticationExpired = ::authenticationExpired,
|
||||
)
|
||||
is Route.WorkItems -> WorkItemsScreen(
|
||||
state = workItemsState,
|
||||
onShowIssues = workItemsViewModel::showIssues,
|
||||
onShowPullRequests = workItemsViewModel::showPullRequests,
|
||||
onIssueSelected = workItemsViewModel::openIssue,
|
||||
onPullRequestSelected = workItemsViewModel::openPullRequest,
|
||||
onNextPage = workItemsViewModel::nextPage,
|
||||
onPreviousPage = workItemsViewModel::previousPage,
|
||||
onGoToPage = workItemsViewModel::goToPage,
|
||||
onRetryList = workItemsViewModel::retryList,
|
||||
onRetryDetail = workItemsViewModel::retryDetail,
|
||||
onBackToList = workItemsViewModel::backToList,
|
||||
onBack = {
|
||||
workItemsViewModel.close()
|
||||
pop()
|
||||
},
|
||||
onAuthenticationExpired = ::authenticationExpired,
|
||||
)
|
||||
is Route.BranchDetail -> BranchDetailScreen(
|
||||
codeBrowserState = codeBrowserState,
|
||||
onEntrySelected = codeBrowserViewModel::openEntry,
|
||||
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
|
||||
onRetryCodeBrowser = codeBrowserViewModel::retry,
|
||||
onCodeBrowserBack = {
|
||||
if (!codeBrowserViewModel.navigateUp()) {
|
||||
codeBrowserViewModel.close()
|
||||
historyViewModel.close()
|
||||
pop()
|
||||
}
|
||||
},
|
||||
onNavigateImage = codeBrowserViewModel::navigateImage,
|
||||
historyState = historyState,
|
||||
onCommitSelected = historyViewModel::openCommit,
|
||||
onHistoryNextPage = historyViewModel::nextPage,
|
||||
onHistoryPreviousPage = historyViewModel::previousPage,
|
||||
onRetryHistoryList = historyViewModel::retryList,
|
||||
onRetryHistoryDetail = historyViewModel::retryDetail,
|
||||
onRetryPatch = historyViewModel::retryPatch,
|
||||
onFileSelected = historyViewModel::openFileDiff,
|
||||
onClearFileSelection = historyViewModel::clearFileSelection,
|
||||
onHistoryBackToList = historyViewModel::backToList,
|
||||
onHistoryBack = {
|
||||
AppShell(
|
||||
selectedTab = currentTab,
|
||||
showNavigation = showNavigation,
|
||||
unreadNotifications = unreadNotifications,
|
||||
onTabSelected = { tab ->
|
||||
if (currentRoute is Route.BranchDetail) {
|
||||
codeBrowserViewModel.close()
|
||||
historyViewModel.close()
|
||||
pop()
|
||||
},
|
||||
onAuthenticationExpired = ::authenticationExpired,
|
||||
)
|
||||
Route.Repositories -> RepositoryListScreen(
|
||||
state = state,
|
||||
onSignIn = viewModel::signIn,
|
||||
onCancelSignIn = viewModel::cancelSignIn,
|
||||
onRetry = viewModel::retry,
|
||||
onLogout = viewModel::logout,
|
||||
onRepositorySelected = viewModel::selectRepository,
|
||||
onReferenceSelected = { repository, name, kind ->
|
||||
viewModel.selectReference(name, kind)
|
||||
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,
|
||||
),
|
||||
}
|
||||
if (currentRoute is Route.WorkItems) workItemsViewModel.close()
|
||||
selectTab(tab)
|
||||
},
|
||||
) { isWide ->
|
||||
// Back on any tab but Home goes Home; back on Home leaves the app.
|
||||
BackHandler(enabled = sessionReady && currentRoute.isTab && currentRoute != Route.Home) {
|
||||
selectTab(AppTab.HOME)
|
||||
}
|
||||
val repositoryList: @Composable () -> Unit = {
|
||||
RepositoryListScreen(
|
||||
state = state,
|
||||
instanceHost = instanceHost,
|
||||
onSignIn = viewModel::signIn,
|
||||
onCancelSignIn = viewModel::cancelSignIn,
|
||||
onRetry = viewModel::retry,
|
||||
onLogout = ::signOut,
|
||||
onRepositorySelected = viewModel::selectRepository,
|
||||
onReferenceSelected = { repository, name, kind ->
|
||||
viewModel.selectReference(name, kind)
|
||||
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,
|
||||
onNextPage = viewModel::nextPage,
|
||||
onPreviousPage = viewModel::previousPage,
|
||||
onRefreshRepositories = viewModel::refreshRepositories,
|
||||
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
|
||||
onToggleFavorite = viewModel::toggleFavorite,
|
||||
onShowAllRepositories = viewModel::showAllRepositories,
|
||||
onShowFavorites = viewModel::showFavorites,
|
||||
onViewWorkItems = { repository ->
|
||||
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
|
||||
},
|
||||
onSelectOrganization = viewModel::selectOrganization,
|
||||
onViewSettings = { push(Route.Settings) },
|
||||
)
|
||||
}
|
||||
when (val route = currentRoute.takeIf { sessionReady } ?: Route.Repositories) {
|
||||
Route.Home -> HomeScreen(
|
||||
state = homeState,
|
||||
accountName = accountName,
|
||||
instanceHost = instanceHost,
|
||||
isWide = isWide,
|
||||
onNavigate = { destination ->
|
||||
when (destination) {
|
||||
HomeDestination.Repositories -> selectTab(AppTab.REPOSITORIES)
|
||||
is HomeDestination.Work -> {
|
||||
workViewModel.open(destination.scope)
|
||||
selectTab(AppTab.WORK)
|
||||
}
|
||||
is HomeDestination.Branch -> push(
|
||||
Route.BranchDetail(
|
||||
owner = destination.owner,
|
||||
name = destination.name,
|
||||
refKind = (if (destination.isTag) CodeReferenceKind.TAG else CodeReferenceKind.BRANCH).name,
|
||||
ref = destination.ref,
|
||||
),
|
||||
)
|
||||
is HomeDestination.WorkItem -> push(
|
||||
Route.WorkItems(destination.owner, destination.name, destination.number, destination.isPullRequest),
|
||||
)
|
||||
}
|
||||
},
|
||||
onSelectOrganization = homeViewModel::selectOrganization,
|
||||
onSelectFilter = homeViewModel::selectFilter,
|
||||
onLoadMore = homeViewModel::loadMore,
|
||||
onRefresh = homeViewModel::refresh,
|
||||
onViewSettings = { push(Route.Settings) },
|
||||
onLogout = ::signOut,
|
||||
onAuthenticationExpired = ::signOut,
|
||||
)
|
||||
Route.Work -> WorkScreen(
|
||||
state = workState,
|
||||
accountName = accountName,
|
||||
instanceHost = instanceHost,
|
||||
onSelectScope = workViewModel::selectScope,
|
||||
onSelectState = workViewModel::selectState,
|
||||
onSelectType = workViewModel::selectType,
|
||||
onItemSelected = { hit ->
|
||||
push(Route.WorkItems(hit.repository.owner, hit.repository.name, hit.number, hit.isPullRequest))
|
||||
},
|
||||
onNextPage = workViewModel::nextPage,
|
||||
onPreviousPage = workViewModel::previousPage,
|
||||
onRefresh = workViewModel::refresh,
|
||||
onViewSettings = { push(Route.Settings) },
|
||||
onLogout = ::signOut,
|
||||
onAuthenticationExpired = ::signOut,
|
||||
)
|
||||
Route.Settings -> SettingsScreen(
|
||||
state = settingsState,
|
||||
onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled,
|
||||
onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled,
|
||||
accountName = accountName,
|
||||
instanceHost = instanceHost,
|
||||
onSetCodeWordWrapEnabled = settingsViewModel::setCodeWordWrapEnabled,
|
||||
onSetCodeFontSizeSp = settingsViewModel::setCodeFontSizeSp,
|
||||
onLogout = ::signOut,
|
||||
onBack = { pop() },
|
||||
)
|
||||
Route.Notifications -> NotificationsScreen(
|
||||
state = notificationsState,
|
||||
onShowUnreadOnly = notificationsViewModel::showUnreadOnly,
|
||||
onShowAll = notificationsViewModel::showAll,
|
||||
onNotificationSelected = { notification ->
|
||||
push(
|
||||
Route.WorkItems(
|
||||
owner = notification.repositoryOwner,
|
||||
name = notification.repositoryName,
|
||||
number = notification.subjectNumber,
|
||||
isPullRequest = notification.subjectType == NotificationSubjectType.PULL_REQUEST,
|
||||
),
|
||||
)
|
||||
},
|
||||
onNextPage = notificationsViewModel::nextPage,
|
||||
onPreviousPage = notificationsViewModel::previousPage,
|
||||
onRetry = notificationsViewModel::retry,
|
||||
onBack = { selectTab(AppTab.HOME) },
|
||||
onAuthenticationExpired = ::signOut,
|
||||
accountName = accountName,
|
||||
instanceHost = instanceHost,
|
||||
onViewSettings = { push(Route.Settings) },
|
||||
onLogout = ::signOut,
|
||||
)
|
||||
is Route.WorkItems -> {
|
||||
val leave = {
|
||||
workItemsViewModel.close()
|
||||
pop()
|
||||
}
|
||||
WorkItemsScreen(
|
||||
state = workItemsState,
|
||||
onShowIssues = workItemsViewModel::showIssues,
|
||||
onShowPullRequests = workItemsViewModel::showPullRequests,
|
||||
onIssueSelected = workItemsViewModel::openIssue,
|
||||
onPullRequestSelected = workItemsViewModel::openPullRequest,
|
||||
onNextPage = workItemsViewModel::nextPage,
|
||||
onPreviousPage = workItemsViewModel::previousPage,
|
||||
onGoToPage = workItemsViewModel::goToPage,
|
||||
onRetryList = workItemsViewModel::retryList,
|
||||
onRetryDetail = workItemsViewModel::retryDetail,
|
||||
// An item opened directly came from a list elsewhere (a search, the
|
||||
// timeline, a notification), so back returns there, not to this
|
||||
// repository's own list.
|
||||
onBackToList = if (route.number != null) leave else workItemsViewModel::backToList,
|
||||
onBack = leave,
|
||||
onAuthenticationExpired = ::signOut,
|
||||
)
|
||||
},
|
||||
onReferencesRefresh = viewModel::refreshReferences,
|
||||
onBackToRepositories = viewModel::backToRepositories,
|
||||
onNextPage = viewModel::nextPage,
|
||||
onPreviousPage = viewModel::previousPage,
|
||||
onRefreshRepositories = viewModel::refreshRepositories,
|
||||
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
|
||||
onToggleFavorite = viewModel::toggleFavorite,
|
||||
onShowAllRepositories = viewModel::showAllRepositories,
|
||||
onShowFavorites = viewModel::showFavorites,
|
||||
onViewWorkItems = { repository ->
|
||||
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
|
||||
},
|
||||
onSelectOrganization = viewModel::selectOrganization,
|
||||
onViewNotifications = { push(Route.Notifications) },
|
||||
onViewSettings = { push(Route.Settings) },
|
||||
)
|
||||
}
|
||||
is Route.BranchDetail -> BranchDetailScreen(
|
||||
owner = route.owner,
|
||||
name = route.name,
|
||||
ref = route.ref,
|
||||
codeBrowserState = codeBrowserState,
|
||||
codeViewerPreferences = codeViewerPreferences,
|
||||
onEntrySelected = codeBrowserViewModel::openEntry,
|
||||
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
|
||||
onRetryCodeBrowser = codeBrowserViewModel::retry,
|
||||
onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) leaveBranchDetail() },
|
||||
onNavigateImage = codeBrowserViewModel::navigateImage,
|
||||
onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled,
|
||||
onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp,
|
||||
onOpenSibling = codeBrowserViewModel::openSibling,
|
||||
onLoadCounterpart = codeBrowserViewModel::loadCounterpart,
|
||||
historyState = historyState,
|
||||
onCommitSelected = historyViewModel::openCommit,
|
||||
onHistoryNextPage = historyViewModel::nextPage,
|
||||
onHistoryPreviousPage = historyViewModel::previousPage,
|
||||
onRetryHistoryList = historyViewModel::retryList,
|
||||
onRetryHistoryDetail = historyViewModel::retryDetail,
|
||||
onRetryPatch = historyViewModel::retryPatch,
|
||||
onFileSelected = historyViewModel::openFileDiff,
|
||||
onShowAdjacentFile = historyViewModel::openAdjacentFileDiff,
|
||||
onClearFileSelection = historyViewModel::clearFileSelection,
|
||||
onHistoryBackToList = historyViewModel::backToList,
|
||||
onHistoryBack = ::leaveBranchDetail,
|
||||
onViewWorkItems = { push(Route.WorkItems(route.owner, route.name)) },
|
||||
onClose = ::leaveBranchDetail,
|
||||
onAuthenticationExpired = ::signOut,
|
||||
)
|
||||
Route.Repositories -> repositoryList()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.browserRequests.collect(::openAuthorizationTab)
|
||||
@ -293,3 +447,28 @@ class MainActivity : ComponentActivity() {
|
||||
pendingFavoritePushRoute = Route.BranchDetail(owner, name, CodeReferenceKind.BRANCH.name, ref)
|
||||
}
|
||||
}
|
||||
|
||||
private val AppTab.route: Route
|
||||
get() = when (this) {
|
||||
AppTab.HOME -> Route.Home
|
||||
AppTab.REPOSITORIES -> Route.Repositories
|
||||
AppTab.WORK -> Route.Work
|
||||
AppTab.NOTIFICATIONS -> Route.Notifications
|
||||
}
|
||||
|
||||
private val Route.tab: AppTab?
|
||||
get() = when (this) {
|
||||
Route.Home -> AppTab.HOME
|
||||
Route.Repositories -> AppTab.REPOSITORIES
|
||||
Route.Work -> AppTab.WORK
|
||||
Route.Notifications -> AppTab.NOTIFICATIONS
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Whether there is a signed-in session, or null when this state does not say either way. */
|
||||
private fun RepositoryListUiState.sessionReadyOrNull(): Boolean? = when (this) {
|
||||
RepositoryListUiState.SignedOut, RepositoryListUiState.WaitingForBrowser -> false
|
||||
RepositoryListUiState.Loading -> null
|
||||
is RepositoryListUiState.Error -> canLogout
|
||||
else -> true
|
||||
}
|
||||
|
||||
@ -17,6 +17,19 @@ interface AppSettingsStore {
|
||||
fun setReactiveAuthRetryEnabled(enabled: Boolean)
|
||||
fun isFavoritePushNotificationsEnabled(): Boolean
|
||||
fun setFavoritePushNotificationsEnabled(enabled: Boolean)
|
||||
|
||||
// How source code is shown (TASK-053). Wrapping defaults ON: a phone shows about 45 characters
|
||||
// of code per line, so an unwrapped file needs sideways scrolling on nearly every line. These
|
||||
// have defaults here so an in-memory test store only has to implement what it exercises.
|
||||
fun isCodeWordWrapEnabled(): Boolean = true
|
||||
fun setCodeWordWrapEnabled(enabled: Boolean) = Unit
|
||||
fun codeFontSizeSp(): Int = DEFAULT_CODE_FONT_SIZE_SP
|
||||
fun setCodeFontSizeSp(size: Int) = Unit
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_CODE_FONT_SIZE_SP = 13
|
||||
val CODE_FONT_SIZES_SP = listOf(11, 12, 13, 14, 16)
|
||||
}
|
||||
}
|
||||
|
||||
class SharedPreferencesAppSettingsStore(context: Context) : AppSettingsStore {
|
||||
@ -36,7 +49,24 @@ class SharedPreferencesAppSettingsStore(context: Context) : AppSettingsStore {
|
||||
preferences.edit().putBoolean(KEY_FAVORITE_PUSH_NOTIFICATIONS, enabled).apply()
|
||||
}
|
||||
|
||||
override fun isCodeWordWrapEnabled(): Boolean = preferences.getBoolean(KEY_CODE_WORD_WRAP, true)
|
||||
|
||||
override fun setCodeWordWrapEnabled(enabled: Boolean) {
|
||||
preferences.edit().putBoolean(KEY_CODE_WORD_WRAP, enabled).apply()
|
||||
}
|
||||
|
||||
override fun codeFontSizeSp(): Int =
|
||||
preferences.getInt(KEY_CODE_FONT_SIZE, AppSettingsStore.DEFAULT_CODE_FONT_SIZE_SP)
|
||||
.takeIf { it in AppSettingsStore.CODE_FONT_SIZES_SP } ?: AppSettingsStore.DEFAULT_CODE_FONT_SIZE_SP
|
||||
|
||||
override fun setCodeFontSizeSp(size: Int) {
|
||||
if (size !in AppSettingsStore.CODE_FONT_SIZES_SP) return
|
||||
preferences.edit().putInt(KEY_CODE_FONT_SIZE, size).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_CODE_WORD_WRAP = "code_word_wrap_enabled"
|
||||
const val KEY_CODE_FONT_SIZE = "code_font_size_sp"
|
||||
const val PREFERENCES_NAME = "ubgitea_app_settings"
|
||||
const val KEY_REACTIVE_AUTH_RETRY = "reactive_auth_retry_enabled"
|
||||
const val KEY_FAVORITE_PUSH_NOTIFICATIONS = "favorite_push_notifications_enabled"
|
||||
|
||||
82
app/src/main/java/com/ubisam/ubgitea/data/GiteaApiSupport.kt
Normal file
82
app/src/main/java/com/ubisam/ubgitea/data/GiteaApiSupport.kt
Normal file
@ -0,0 +1,82 @@
|
||||
package com.ubisam.ubgitea.data
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParseException
|
||||
import com.ubisam.ubgitea.core.auth.protocol.OAuthClientConfig
|
||||
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||
import com.ubisam.ubgitea.core.network.HttpResponse
|
||||
import com.ubisam.ubgitea.core.network.HttpTransport
|
||||
import com.ubisam.ubgitea.core.network.requireSuccess
|
||||
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
|
||||
import com.ubisam.ubgitea.data.repositories.callAuthenticated
|
||||
import com.ubisam.ubgitea.domain.model.Page
|
||||
import java.io.IOException
|
||||
import java.lang.reflect.Type
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.format.DateTimeParseException
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
|
||||
/**
|
||||
* The request/response plumbing the activity and work-search API classes share. The older API
|
||||
* classes each carry their own private copy of these helpers; new ones use this instead of adding
|
||||
* a fifth and sixth copy.
|
||||
*/
|
||||
internal class GiteaApiSupport(
|
||||
private val config: OAuthClientConfig,
|
||||
private val transport: HttpTransport,
|
||||
private val tokenProvider: BearerTokenProvider,
|
||||
private val reactiveAuthRetryEnabled: () -> Boolean,
|
||||
val gson: Gson,
|
||||
) {
|
||||
fun apiUrl(vararg segments: String): HttpUrl.Builder = config.baseUri.toString().toHttpUrl()
|
||||
.newBuilder()
|
||||
.addPathSegment("api")
|
||||
.addPathSegment("v1")
|
||||
.apply { segments.forEach(::addPathSegment) }
|
||||
|
||||
fun HttpUrl.Builder.withPage(page: Int, pageSize: Int): HttpUrl.Builder =
|
||||
addQueryParameter("page", page.toString()).addQueryParameter("limit", pageSize.toString())
|
||||
|
||||
suspend fun get(url: HttpUrl): HttpResponse =
|
||||
callAuthenticated(tokenProvider, reactiveAuthRetryEnabled) { token ->
|
||||
try {
|
||||
transport.get(url.toString(), token).requireSuccess()
|
||||
} catch (failure: IOException) {
|
||||
throw ApiFailure.Network(failure)
|
||||
}
|
||||
}
|
||||
|
||||
/** A literal JSON `null` body is an empty list: Gitea sends one for an empty slice on some endpoints (TASK-023). */
|
||||
fun <T> parseList(body: String, type: Type): List<T> = try {
|
||||
gson.fromJson<List<T>>(body, type) ?: emptyList()
|
||||
} catch (_: JsonParseException) {
|
||||
throw ApiFailure.InvalidResponse()
|
||||
}
|
||||
|
||||
fun <T> HttpResponse.toPage(items: List<T>, page: Int, pageSize: Int, receivedCount: Int = items.size): Page<T> {
|
||||
val hasMore = header("X-HasMore")?.equals("true", ignoreCase = true)
|
||||
val needsNext = hasMore == true || hasMore == null && receivedCount == pageSize
|
||||
val nextPage = if (!needsNext) null else if (page == Int.MAX_VALUE) throw ApiFailure.InvalidResponse() else page + 1
|
||||
return Page.of(items, nextPage, totalCount())
|
||||
}
|
||||
|
||||
fun HttpResponse.totalCount(): Int? = header("X-Total-Count")?.toIntOrNull()?.takeIf { it >= 0 }
|
||||
|
||||
private fun HttpResponse.header(name: String): String? =
|
||||
headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value
|
||||
|
||||
fun validatePage(page: Int, pageSize: Int) {
|
||||
require(page > 0) { "Page must be positive" }
|
||||
require(pageSize in 1..100) { "Page size must be between 1 and 100" }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun String?.requiredText(): String = takeIf { !it.isNullOrBlank() } ?: throw ApiFailure.InvalidResponse()
|
||||
|
||||
/** Accepts both `...Z` and an explicit offset such as `+09:00`, which a Gitea server in a local time zone sends. */
|
||||
internal fun String.toEpochMillisOrInvalid(): Long = try {
|
||||
OffsetDateTime.parse(this).toInstant().toEpochMilli()
|
||||
} catch (_: DateTimeParseException) {
|
||||
throw ApiFailure.InvalidResponse()
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
package com.ubisam.ubgitea.data.activity
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParseException
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.ubisam.ubgitea.core.auth.protocol.OAuthClientConfig
|
||||
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||
import com.ubisam.ubgitea.core.network.HttpTransport
|
||||
import com.ubisam.ubgitea.data.GiteaApiSupport
|
||||
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
|
||||
import com.ubisam.ubgitea.data.requiredText
|
||||
import com.ubisam.ubgitea.data.toEpochMillisOrInvalid
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityCategory
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityCommit
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityItem
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityKind
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityRepository
|
||||
import com.ubisam.ubgitea.domain.model.Page
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import java.time.LocalDate
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* Read-only Gitea activity feeds (`/users/{name}/activities/feeds`, `/orgs/{org}/activities/feeds`).
|
||||
*
|
||||
* An activity's `content` has no schema in Gitea's API description; its shape depends on the
|
||||
* activity type and was taken from Gitea's source rather than confirmed against this instance: a
|
||||
* push carries a JSON object listing its commits, an issue or pull-request activity carries
|
||||
* `"<number>|<text>"`. Both are therefore read leniently -- content that does not parse costs a row
|
||||
* its detail, never the feed. Starring, watching and mirror syncing are dropped: they are not team
|
||||
* work anyone needs to see on a timeline.
|
||||
*/
|
||||
class GiteaActivityApi(
|
||||
config: OAuthClientConfig,
|
||||
transport: HttpTransport,
|
||||
tokenProvider: BearerTokenProvider,
|
||||
gson: Gson = Gson(),
|
||||
reactiveAuthRetryEnabled: () -> Boolean = { false },
|
||||
) : ActivityRepository {
|
||||
private val support = GiteaApiSupport(config, transport, tokenProvider, reactiveAuthRetryEnabled, gson)
|
||||
|
||||
override suspend fun listUserFeed(login: String, date: LocalDate?, page: Int, pageSize: Int): Page<ActivityItem> {
|
||||
require(login.isNotBlank()) { "Login cannot be blank" }
|
||||
return listFeed(support.apiUrl("users", login, "activities", "feeds"), date, page, pageSize)
|
||||
}
|
||||
|
||||
override suspend fun listOrganizationFeed(
|
||||
organization: String,
|
||||
date: LocalDate?,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
): Page<ActivityItem> {
|
||||
require(organization.isNotBlank()) { "Organization cannot be blank" }
|
||||
return listFeed(support.apiUrl("orgs", organization, "activities", "feeds"), date, page, pageSize)
|
||||
}
|
||||
|
||||
private suspend fun listFeed(url: HttpUrl.Builder, date: LocalDate?, page: Int, pageSize: Int): Page<ActivityItem> =
|
||||
with(support) {
|
||||
validatePage(page, pageSize)
|
||||
if (date != null) url.addQueryParameter("date", date.toString())
|
||||
val response = get(url.withPage(page, pageSize).build())
|
||||
val dtos = parseList<ActivityDto>(response.body, ACTIVITY_LIST_TYPE)
|
||||
// The next page is decided by how many rows the server sent, not how many survived
|
||||
// mapping: a page full of starring activity still has a page after it.
|
||||
response.toPage(dtos.mapNotNull(::mapActivity), page, pageSize, receivedCount = dtos.size)
|
||||
}
|
||||
|
||||
private fun mapActivity(dto: ActivityDto): ActivityItem? {
|
||||
val kind = dto.opType.toActivityKind() ?: return null
|
||||
val repository = dto.repo?.toCoordinatesOrNull() ?: return null
|
||||
val content = dto.content.orEmpty()
|
||||
val push = if (kind == ActivityKind.PUSH) parsePush(content) else null
|
||||
val subject = if (kind.hasSubject()) parseSubject(content) else null
|
||||
return ActivityItem(
|
||||
id = dto.id ?: throw ApiFailure.InvalidResponse(),
|
||||
kind = kind,
|
||||
actorLogin = dto.actUser?.login.requiredText(),
|
||||
actorDisplayName = dto.actUser?.fullName?.takeIf(String::isNotBlank),
|
||||
repository = repository,
|
||||
refName = dto.refName?.removeRefPrefix()?.takeIf(String::isNotBlank),
|
||||
subjectNumber = subject?.first,
|
||||
subjectText = subject?.second,
|
||||
commits = push?.commits.orEmpty(),
|
||||
commitCount = push?.count ?: 0,
|
||||
createdAtEpochMillis = dto.created.requiredText().toEpochMillisOrInvalid(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ActivityKind.hasSubject(): Boolean =
|
||||
category == ActivityCategory.PULL_REQUEST || category == ActivityCategory.ISSUE
|
||||
|
||||
private class Push(val commits: List<ActivityCommit>, val count: Int)
|
||||
|
||||
private fun parsePush(content: String): Push? {
|
||||
val dto = try {
|
||||
support.gson.fromJson(content, PushContentDto::class.java)
|
||||
} catch (_: JsonParseException) {
|
||||
null
|
||||
} ?: return null
|
||||
val commits = dto.commits.orEmpty().mapNotNull { commit ->
|
||||
val id = commit.sha1?.takeIf(String::isNotBlank) ?: return@mapNotNull null
|
||||
ActivityCommit(id.take(SHORT_ID_LENGTH), commit.message.orEmpty().lineSequence().firstOrNull().orEmpty().trim())
|
||||
}
|
||||
return Push(commits, maxOf(dto.len ?: 0, commits.size))
|
||||
}
|
||||
|
||||
private fun parseSubject(content: String): Pair<Long?, String?> {
|
||||
val separator = content.indexOf('|')
|
||||
if (separator <= 0) return null to content.trim().takeIf(String::isNotEmpty)
|
||||
val number = content.substring(0, separator).trim().toLongOrNull()
|
||||
val text = content.substring(separator + 1).trim().takeIf(String::isNotEmpty)
|
||||
return number to text
|
||||
}
|
||||
|
||||
private fun RepoDto.toCoordinatesOrNull(): RepositoryCoordinates? {
|
||||
val owner = owner?.login?.takeIf(String::isNotBlank) ?: return null
|
||||
val name = name?.takeIf(String::isNotBlank) ?: return null
|
||||
return try {
|
||||
RepositoryCoordinates.of(owner, name)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.removeRefPrefix(): String = removePrefix("refs/heads/").removePrefix("refs/tags/")
|
||||
|
||||
private fun String?.toActivityKind(): ActivityKind? = when (this) {
|
||||
"commit_repo" -> ActivityKind.PUSH
|
||||
"push_tag" -> ActivityKind.TAG_PUSHED
|
||||
"delete_branch" -> ActivityKind.BRANCH_DELETED
|
||||
"delete_tag" -> ActivityKind.TAG_DELETED
|
||||
"create_pull_request", "pull_request_ready_for_review" -> ActivityKind.PULL_REQUEST_OPENED
|
||||
"merge_pull_request", "auto_merge_pull_request" -> ActivityKind.PULL_REQUEST_MERGED
|
||||
"close_pull_request" -> ActivityKind.PULL_REQUEST_CLOSED
|
||||
"reopen_pull_request" -> ActivityKind.PULL_REQUEST_REOPENED
|
||||
"approve_pull_request" -> ActivityKind.PULL_REQUEST_APPROVED
|
||||
"reject_pull_request" -> ActivityKind.PULL_REQUEST_CHANGES_REQUESTED
|
||||
"comment_pull", "pull_review_dismissed" -> ActivityKind.PULL_REQUEST_COMMENTED
|
||||
"create_issue" -> ActivityKind.ISSUE_OPENED
|
||||
"close_issue" -> ActivityKind.ISSUE_CLOSED
|
||||
"reopen_issue" -> ActivityKind.ISSUE_REOPENED
|
||||
"comment_issue" -> ActivityKind.ISSUE_COMMENTED
|
||||
"publish_release" -> ActivityKind.RELEASE_PUBLISHED
|
||||
"create_repo" -> ActivityKind.REPOSITORY_CREATED
|
||||
"star_repo", "watch_repo", "mirror_sync_push", "mirror_sync_create", "mirror_sync_delete" -> null
|
||||
else -> ActivityKind.OTHER
|
||||
}
|
||||
|
||||
private data class UserDto(val login: String?, @SerializedName("full_name") val fullName: String?)
|
||||
private data class RepoDto(val name: String?, val owner: UserDto?)
|
||||
private data class ActivityDto(
|
||||
val id: Long?,
|
||||
@SerializedName("op_type") val opType: String?,
|
||||
@SerializedName("act_user") val actUser: UserDto?,
|
||||
val repo: RepoDto?,
|
||||
@SerializedName("ref_name") val refName: String?,
|
||||
val content: String?,
|
||||
val created: String?,
|
||||
)
|
||||
private data class PushCommitDto(
|
||||
@SerializedName("Sha1") val sha1: String?,
|
||||
@SerializedName("Message") val message: String?,
|
||||
)
|
||||
private data class PushContentDto(
|
||||
@SerializedName("Commits") val commits: List<PushCommitDto>?,
|
||||
@SerializedName("Len") val len: Int?,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val SHORT_ID_LENGTH = 7
|
||||
val ACTIVITY_LIST_TYPE = object : TypeToken<List<ActivityDto>>() {}.type
|
||||
}
|
||||
}
|
||||
@ -49,6 +49,15 @@ class GiteaNotificationsApi(
|
||||
return response.toPage(items, page, pageSize)
|
||||
}
|
||||
|
||||
override suspend fun countUnread(): Int? {
|
||||
val body = get(apiUrl("api", "v1", "notifications", "new")).body
|
||||
return try {
|
||||
gson.fromJson(body, UnreadCountDto::class.java)?.new?.takeIf { it >= 0 }
|
||||
} catch (_: JsonParseException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapNotification(dto: NotificationDto): NotificationSummary {
|
||||
val subject = dto.subject ?: throw ApiFailure.InvalidResponse()
|
||||
val repositoryOwner = dto.repository?.owner?.login.required()
|
||||
@ -137,6 +146,7 @@ class GiteaNotificationsApi(
|
||||
private fun Int.safeNextPage(): Int =
|
||||
if (this == Int.MAX_VALUE) throw ApiFailure.InvalidResponse() else this + 1
|
||||
|
||||
private data class UnreadCountDto(val new: Int?)
|
||||
private data class OwnerDto(val login: String?)
|
||||
private data class RepositoryDto(val owner: OwnerDto?, val name: String?)
|
||||
private data class SubjectDto(val title: String?, val url: String?, val type: String?, val state: String?)
|
||||
|
||||
@ -0,0 +1,112 @@
|
||||
package com.ubisam.ubgitea.data.workitems
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.ubisam.ubgitea.core.auth.protocol.OAuthClientConfig
|
||||
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||
import com.ubisam.ubgitea.core.network.HttpTransport
|
||||
import com.ubisam.ubgitea.data.GiteaApiSupport
|
||||
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
|
||||
import com.ubisam.ubgitea.data.requiredText
|
||||
import com.ubisam.ubgitea.data.toEpochMillisOrInvalid
|
||||
import com.ubisam.ubgitea.domain.model.Page
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkItemAuthor
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkItemHit
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkScope
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchRepository
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkStateFilter
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkTypeFilter
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* Read-only `/repos/issues/search`: issues and pull requests across every repository the signed-in
|
||||
* account can see. Gitea applies the `assigned` / `created` / `review_requested` flags together
|
||||
* (each narrows the result), so a [WorkScope] sets exactly one of them.
|
||||
*/
|
||||
class GiteaWorkSearchApi(
|
||||
config: OAuthClientConfig,
|
||||
transport: HttpTransport,
|
||||
tokenProvider: BearerTokenProvider,
|
||||
gson: Gson = Gson(),
|
||||
reactiveAuthRetryEnabled: () -> Boolean = { false },
|
||||
) : WorkSearchRepository {
|
||||
private val support = GiteaApiSupport(config, transport, tokenProvider, reactiveAuthRetryEnabled, gson)
|
||||
|
||||
override suspend fun search(query: WorkSearchQuery, page: Int, pageSize: Int): Page<WorkItemHit> = with(support) {
|
||||
validatePage(page, pageSize)
|
||||
val response = get(searchUrl(query).withPage(page, pageSize).build())
|
||||
val dtos = parseList<HitDto>(response.body, HIT_LIST_TYPE)
|
||||
response.toPage(dtos.map(::mapHit), page, pageSize)
|
||||
}
|
||||
|
||||
override suspend fun count(query: WorkSearchQuery): Int? = with(support) {
|
||||
get(searchUrl(query).withPage(1, 1).build()).totalCount()
|
||||
}
|
||||
|
||||
private fun searchUrl(query: WorkSearchQuery): HttpUrl.Builder {
|
||||
val url = support.apiUrl("repos", "issues", "search")
|
||||
url.addQueryParameter("state", if (query.state == WorkStateFilter.OPEN) "open" else "closed")
|
||||
when (query.type) {
|
||||
WorkTypeFilter.ALL -> Unit
|
||||
WorkTypeFilter.ISSUES -> url.addQueryParameter("type", "issues")
|
||||
WorkTypeFilter.PULL_REQUESTS -> url.addQueryParameter("type", "pulls")
|
||||
}
|
||||
when (query.scope) {
|
||||
WorkScope.EVERYONE -> Unit
|
||||
WorkScope.REVIEW_REQUESTED -> url.addQueryParameter("review_requested", "true")
|
||||
WorkScope.ASSIGNED_TO_ME -> url.addQueryParameter("assigned", "true")
|
||||
WorkScope.CREATED_BY_ME -> url.addQueryParameter("created", "true")
|
||||
}
|
||||
query.owner?.takeIf(String::isNotBlank)?.let { url.addQueryParameter("owner", it) }
|
||||
return url
|
||||
}
|
||||
|
||||
private fun mapHit(dto: HitDto): WorkItemHit {
|
||||
val repository = try {
|
||||
RepositoryCoordinates.of(dto.repository?.owner.requiredText(), dto.repository?.name.requiredText())
|
||||
} catch (_: IllegalArgumentException) {
|
||||
throw ApiFailure.InvalidResponse()
|
||||
}
|
||||
val login = dto.user?.login?.takeIf(String::isNotBlank)
|
||||
return WorkItemHit(
|
||||
repository = repository,
|
||||
number = dto.number ?: throw ApiFailure.InvalidResponse(),
|
||||
title = dto.title.requiredText(),
|
||||
isPullRequest = dto.pullRequest != null,
|
||||
isOpen = when (dto.state) {
|
||||
"open" -> true
|
||||
"closed" -> false
|
||||
else -> throw ApiFailure.InvalidResponse()
|
||||
},
|
||||
isMerged = dto.pullRequest?.merged == true,
|
||||
author = if (login == null) {
|
||||
WorkItemAuthor.Unavailable
|
||||
} else {
|
||||
WorkItemAuthor.Known(login, dto.user?.fullName?.takeIf(String::isNotBlank))
|
||||
},
|
||||
updatedAtEpochMillis = dto.updatedAt.requiredText().toEpochMillisOrInvalid(),
|
||||
commentCount = dto.comments ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
private data class UserDto(val login: String?, @SerializedName("full_name") val fullName: String?)
|
||||
private data class RepositoryMetaDto(val name: String?, val owner: String?)
|
||||
private data class PullRequestMetaDto(val merged: Boolean?)
|
||||
private data class HitDto(
|
||||
val number: Long?,
|
||||
val title: String?,
|
||||
val state: String?,
|
||||
val user: UserDto?,
|
||||
val comments: Int?,
|
||||
val repository: RepositoryMetaDto?,
|
||||
@SerializedName("updated_at") val updatedAt: String?,
|
||||
@SerializedName("pull_request") val pullRequest: PullRequestMetaDto?,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val HIT_LIST_TYPE = object : TypeToken<List<HitDto>>() {}.type
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package com.ubisam.ubgitea.domain.activity
|
||||
|
||||
import com.ubisam.ubgitea.domain.model.Page
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* What happened, as the home timeline tells it. OTHER covers any Gitea `op_type` this client does
|
||||
* not yet recognize, so a new server-side activity type shows as a generic row instead of failing
|
||||
* the whole feed closed.
|
||||
*/
|
||||
enum class ActivityKind(val category: ActivityCategory) {
|
||||
PUSH(ActivityCategory.PUSH),
|
||||
TAG_PUSHED(ActivityCategory.PUSH),
|
||||
BRANCH_DELETED(ActivityCategory.PUSH),
|
||||
TAG_DELETED(ActivityCategory.PUSH),
|
||||
PULL_REQUEST_OPENED(ActivityCategory.PULL_REQUEST),
|
||||
PULL_REQUEST_MERGED(ActivityCategory.PULL_REQUEST),
|
||||
PULL_REQUEST_CLOSED(ActivityCategory.PULL_REQUEST),
|
||||
PULL_REQUEST_REOPENED(ActivityCategory.PULL_REQUEST),
|
||||
PULL_REQUEST_APPROVED(ActivityCategory.PULL_REQUEST),
|
||||
PULL_REQUEST_CHANGES_REQUESTED(ActivityCategory.PULL_REQUEST),
|
||||
PULL_REQUEST_COMMENTED(ActivityCategory.PULL_REQUEST),
|
||||
ISSUE_OPENED(ActivityCategory.ISSUE),
|
||||
ISSUE_CLOSED(ActivityCategory.ISSUE),
|
||||
ISSUE_REOPENED(ActivityCategory.ISSUE),
|
||||
ISSUE_COMMENTED(ActivityCategory.ISSUE),
|
||||
RELEASE_PUBLISHED(ActivityCategory.OTHER),
|
||||
REPOSITORY_CREATED(ActivityCategory.OTHER),
|
||||
OTHER(ActivityCategory.OTHER),
|
||||
}
|
||||
|
||||
enum class ActivityCategory { PUSH, PULL_REQUEST, ISSUE, OTHER }
|
||||
|
||||
data class ActivityCommit(val shortId: String, val message: String)
|
||||
|
||||
data class ActivityItem(
|
||||
val id: Long,
|
||||
val kind: ActivityKind,
|
||||
val actorLogin: String,
|
||||
val actorDisplayName: String?,
|
||||
val repository: RepositoryCoordinates,
|
||||
/** Branch or tag name with any `refs/heads/` / `refs/tags/` prefix removed; null when the activity has none. */
|
||||
val refName: String?,
|
||||
/** Issue or pull-request number; null for anything that is not about one. */
|
||||
val subjectNumber: Long?,
|
||||
/** Issue or pull-request title, or for a comment activity the start of the comment. */
|
||||
val subjectText: String?,
|
||||
/** The commits a push carried, newest first, as far as the feed lists them. */
|
||||
val commits: List<ActivityCommit>,
|
||||
/** How many commits the push carried in total; can exceed [commits]' size. */
|
||||
val commitCount: Int,
|
||||
val createdAtEpochMillis: Long,
|
||||
)
|
||||
|
||||
/** Read-only. Activity is reported by Gitea; nothing here records or changes any. */
|
||||
interface ActivityRepository {
|
||||
/** The signed-in user's dashboard feed: activity in every repository that account follows. */
|
||||
suspend fun listUserFeed(login: String, date: LocalDate?, page: Int, pageSize: Int): Page<ActivityItem>
|
||||
|
||||
suspend fun listOrganizationFeed(organization: String, date: LocalDate?, page: Int, pageSize: Int): Page<ActivityItem>
|
||||
}
|
||||
@ -19,6 +19,23 @@ data class DiffRow(val left: DiffLine?, val right: DiffLine?) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of a diff as it is shown, which is more than [DiffRow] carries: line numbers on each
|
||||
* side, and a row of its own for each hunk's header. A header row has neither side and carries
|
||||
* [hunkLabel] -- the text Git puts after the second `@@`, usually the enclosing function -- which
|
||||
* is empty when Git supplied none.
|
||||
*/
|
||||
data class DiffEntry(
|
||||
val left: DiffLine?,
|
||||
val right: DiffLine?,
|
||||
val leftNumber: Int? = null,
|
||||
val rightNumber: Int? = null,
|
||||
val hunkLabel: String? = null,
|
||||
) {
|
||||
val isHunkHeader: Boolean get() = hunkLabel != null
|
||||
val isChange: Boolean get() = left is DiffLine.Removed || right is DiffLine.Added
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits one file's slice out of a combined multi-file `.diff`/patch body (as returned by
|
||||
* Gitea's raw diff endpoints) and lays it out as side-by-side rows. Reused across commit and
|
||||
@ -38,6 +55,31 @@ object UnifiedDiffParser {
|
||||
return rows
|
||||
}
|
||||
|
||||
/** The same rows as [parseFile], numbered, with a header row in front of every hunk. */
|
||||
fun parseFileEntries(combinedPatch: String, path: String): List<DiffEntry> {
|
||||
val section = splitIntoFileSections(combinedPatch).firstOrNull { sectionPath(it) == path }
|
||||
?: return emptyList()
|
||||
val headers = section.filter { it.startsWith("@@") }
|
||||
val entries = mutableListOf<DiffEntry>()
|
||||
parseHunks(section).forEachIndexed { index, hunk ->
|
||||
val header = HUNK_HEADER.find(headers.getOrElse(index) { "" })
|
||||
var leftNumber = header?.groupValues?.get(1)?.toIntOrNull() ?: 1
|
||||
var rightNumber = header?.groupValues?.get(2)?.toIntOrNull() ?: 1
|
||||
entries += DiffEntry(null, null, hunkLabel = header?.groupValues?.get(3)?.trim().orEmpty())
|
||||
hunkToRows(hunk).forEach { row ->
|
||||
entries += DiffEntry(
|
||||
left = row.left,
|
||||
right = row.right,
|
||||
leftNumber = if (row.left != null) leftNumber++ else null,
|
||||
rightNumber = if (row.right != null) rightNumber++ else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private val HUNK_HEADER = Regex("^@@ -(\\d+)(?:,\\d+)? \\+(\\d+)(?:,\\d+)? @@(.*)$")
|
||||
|
||||
private fun splitIntoFileSections(patch: String): List<List<String>> {
|
||||
val sections = mutableListOf<MutableList<String>>()
|
||||
for (line in patch.lineSequence()) {
|
||||
|
||||
@ -8,4 +8,7 @@ import com.ubisam.ubgitea.domain.model.Page
|
||||
*/
|
||||
interface NotificationsRepository {
|
||||
suspend fun listNotifications(page: Int, pageSize: Int, unreadOnly: Boolean): Page<NotificationSummary>
|
||||
|
||||
/** How many notifications are unread; null when that cannot be told. */
|
||||
suspend fun countUnread(): Int? = null
|
||||
}
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
package com.ubisam.ubgitea.domain.workitems
|
||||
|
||||
import com.ubisam.ubgitea.domain.model.Page
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
|
||||
/** Whose work a cross-repository search is about. Each maps to exactly one Gitea search flag. */
|
||||
enum class WorkScope { EVERYONE, REVIEW_REQUESTED, ASSIGNED_TO_ME, CREATED_BY_ME }
|
||||
|
||||
enum class WorkStateFilter { OPEN, CLOSED }
|
||||
|
||||
enum class WorkTypeFilter { ALL, ISSUES, PULL_REQUESTS }
|
||||
|
||||
data class WorkSearchQuery(
|
||||
val scope: WorkScope = WorkScope.EVERYONE,
|
||||
val state: WorkStateFilter = WorkStateFilter.OPEN,
|
||||
val type: WorkTypeFilter = WorkTypeFilter.ALL,
|
||||
/** Limits results to repositories owned by this organization or user; null for every accessible repository. */
|
||||
val owner: String? = null,
|
||||
)
|
||||
|
||||
/** One issue or pull request found across repositories. A merged pull request is CLOSED with [isMerged] set. */
|
||||
data class WorkItemHit(
|
||||
val repository: RepositoryCoordinates,
|
||||
val number: Long,
|
||||
val title: String,
|
||||
val isPullRequest: Boolean,
|
||||
val isOpen: Boolean,
|
||||
val isMerged: Boolean,
|
||||
val author: WorkItemAuthor,
|
||||
val updatedAtEpochMillis: Long,
|
||||
val commentCount: Int,
|
||||
)
|
||||
|
||||
/** Read-only search across every repository the signed-in account can see. */
|
||||
interface WorkSearchRepository {
|
||||
suspend fun search(query: WorkSearchQuery, page: Int, pageSize: Int): Page<WorkItemHit>
|
||||
|
||||
/** How many items match, without fetching them. Null when the server does not report a total. */
|
||||
suspend fun count(query: WorkSearchQuery): Int?
|
||||
}
|
||||
@ -2,9 +2,10 @@ 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.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
@ -12,33 +13,49 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.domain.history.FileChange
|
||||
import com.ubisam.ubgitea.domain.model.CommitSummary
|
||||
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.codebrowser.content.ContentClassification
|
||||
import com.ubisam.ubgitea.feature.codebrowser.CodeViewerPreferences
|
||||
import com.ubisam.ubgitea.feature.codebrowser.FileViewerScreen
|
||||
import com.ubisam.ubgitea.feature.codebrowser.isFileLevel
|
||||
import com.ubisam.ubgitea.feature.history.FileDiffScreen
|
||||
import com.ubisam.ubgitea.feature.history.HistoryScreen
|
||||
import com.ubisam.ubgitea.feature.history.HistoryUiState
|
||||
import com.ubisam.ubgitea.feature.history.showsFileDiff
|
||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* One branch/tag/commit ref, shown as two swipeable tabs — Files (the code browser, default) and
|
||||
* Commit — 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`).
|
||||
* One branch/tag/commit ref, shown as two swipeable tabs — Files (the directory listing, default)
|
||||
* and Commit — over the same ref. Each tab keeps its own ViewModel and its own drill-down.
|
||||
*
|
||||
* The tabs only ever hold lists. A file, and a file's diff, open full screen in place of the
|
||||
* tabs: both scroll sideways, and neither belongs inside a pager that swipes sideways. With only
|
||||
* lists left in it, the pager's swipe is always on.
|
||||
*/
|
||||
@Composable
|
||||
fun BranchDetailScreen(
|
||||
owner: String,
|
||||
name: String,
|
||||
ref: String,
|
||||
codeBrowserState: CodeBrowserUiState,
|
||||
codeViewerPreferences: CodeViewerPreferences,
|
||||
onEntrySelected: (TreeEntry) -> Unit,
|
||||
onBreadcrumbSelected: (RepositoryPath) -> Unit,
|
||||
onRetryCodeBrowser: () -> Unit,
|
||||
onCodeBrowserBack: () -> Unit,
|
||||
onNavigateImage: (Int) -> Unit,
|
||||
onSetCodeWordWrap: (Boolean) -> Unit,
|
||||
onSetCodeFontSize: (Int) -> Unit,
|
||||
onOpenSibling: (RepositoryPath, String?) -> Unit,
|
||||
onLoadCounterpart: () -> Unit,
|
||||
historyState: HistoryUiState,
|
||||
onCommitSelected: (CommitSummary) -> Unit,
|
||||
onHistoryNextPage: () -> Unit,
|
||||
@ -47,73 +64,88 @@ fun BranchDetailScreen(
|
||||
onRetryHistoryDetail: () -> Unit,
|
||||
onRetryPatch: () -> Unit,
|
||||
onFileSelected: (FileChange) -> Unit,
|
||||
onShowAdjacentFile: (Int) -> Unit,
|
||||
onClearFileSelection: () -> Unit,
|
||||
onHistoryBackToList: () -> Unit,
|
||||
onHistoryBack: () -> Unit,
|
||||
onViewWorkItems: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onAuthenticationExpired: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pagerState = rememberPagerState(pageCount = { 2 })
|
||||
val scope = rememberCoroutineScope()
|
||||
// A page showing horizontally-gestured content — scrollable source text, a diff's before/after
|
||||
// panels, an image gallery's swipe-to-navigate, or a CSV table's horizontal scroll — has its
|
||||
// own left/right drag handling that directly conflicts with the pager's own — both are
|
||||
// horizontal, so a drag meant for that content was instead partly consumed by the pager, which
|
||||
// is what made scrolling code feel jittery and occasionally scroll text out of view entirely.
|
||||
// Swiping between tabs only makes sense one level up from any of that anyway (Files' folder
|
||||
// listing, History's changed-file list), so the pager's own gesture is disabled while either
|
||||
// tab is showing one of these, and re-enabled once back at that list.
|
||||
val currentPageShowsScrollableCode = when (pagerState.currentPage) {
|
||||
0 -> codeBrowserState is CodeBrowserUiState.File &&
|
||||
(codeBrowserState.content is ContentClassification.Text ||
|
||||
codeBrowserState.content is ContentClassification.Image ||
|
||||
codeBrowserState.content is ContentClassification.Csv ||
|
||||
codeBrowserState.content is ContentClassification.Pdf)
|
||||
else -> historyState is HistoryUiState.CommitDetailContent && historyState.selectedFile != null
|
||||
}
|
||||
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)) },
|
||||
when {
|
||||
pagerState.currentPage == FILES_PAGE && codeBrowserState.isFileLevel -> FileViewerScreen(
|
||||
state = codeBrowserState,
|
||||
preferences = codeViewerPreferences,
|
||||
onBack = onCodeBrowserBack,
|
||||
onRetry = onRetryCodeBrowser,
|
||||
onAuthenticationExpired = onAuthenticationExpired,
|
||||
onNavigateImage = onNavigateImage,
|
||||
onSetWordWrap = onSetCodeWordWrap,
|
||||
onSetFontSize = onSetCodeFontSize,
|
||||
onOpenSibling = onOpenSibling,
|
||||
onOpenEntry = onEntrySelected,
|
||||
onLoadCounterpart = onLoadCounterpart,
|
||||
modifier = modifier,
|
||||
)
|
||||
pagerState.currentPage == COMMIT_PAGE && historyState is HistoryUiState.CommitDetailContent && historyState.showsFileDiff ->
|
||||
FileDiffScreen(
|
||||
state = historyState,
|
||||
fontSizeSp = codeViewerPreferences.fontSizeSp,
|
||||
onShowAdjacentFile = onShowAdjacentFile,
|
||||
onClose = onClearFileSelection,
|
||||
modifier = modifier,
|
||||
)
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 1,
|
||||
onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
|
||||
text = { Text(stringResource(R.string.branchdetail_tab_history)) },
|
||||
)
|
||||
}
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = !currentPageShowsScrollableCode,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> CodeBrowserScreen(
|
||||
state = codeBrowserState,
|
||||
onEntrySelected = onEntrySelected,
|
||||
onBreadcrumbSelected = onBreadcrumbSelected,
|
||||
onRetry = onRetryCodeBrowser,
|
||||
onBack = onCodeBrowserBack,
|
||||
onAuthenticationExpired = onAuthenticationExpired,
|
||||
onNavigateImage = onNavigateImage,
|
||||
)
|
||||
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,
|
||||
)
|
||||
else -> Column(modifier.fillMaxSize()) {
|
||||
ScreenTopBar(title = name, subtitle = stringResource(R.string.branchdetail_subtitle, owner, ref), onBack = onClose) {
|
||||
IconButton(onClick = onViewWorkItems) {
|
||||
UBIcon(R.drawable.ic_ub_pull_request, stringResource(R.string.repositories_work_items))
|
||||
}
|
||||
}
|
||||
TabRow(selectedTabIndex = pagerState.currentPage, containerColor = MaterialTheme.colorScheme.surface) {
|
||||
listOf(R.string.branchdetail_tab_files, R.string.branchdetail_tab_history).forEachIndexed { page, label ->
|
||||
val selected = pagerState.currentPage == page
|
||||
Tab(
|
||||
selected = selected,
|
||||
onClick = { scope.launch { pagerState.animateScrollToPage(page) } },
|
||||
text = { Text(stringResource(label), fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium) },
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page ->
|
||||
when (page) {
|
||||
FILES_PAGE -> CodeBrowserScreen(
|
||||
state = codeBrowserState,
|
||||
onEntrySelected = onEntrySelected,
|
||||
onBreadcrumbSelected = onBreadcrumbSelected,
|
||||
onRetry = onRetryCodeBrowser,
|
||||
onBack = onCodeBrowserBack,
|
||||
onAuthenticationExpired = onAuthenticationExpired,
|
||||
)
|
||||
else -> HistoryScreen(
|
||||
state = historyState,
|
||||
onCommitSelected = onCommitSelected,
|
||||
onNextPage = onHistoryNextPage,
|
||||
onPreviousPage = onHistoryPreviousPage,
|
||||
onRetryList = onRetryHistoryList,
|
||||
onRetryDetail = onRetryHistoryDetail,
|
||||
onRetryPatch = onRetryPatch,
|
||||
onFileSelected = onFileSelected,
|
||||
onBackToList = onHistoryBackToList,
|
||||
onBack = onHistoryBack,
|
||||
onAuthenticationExpired = onAuthenticationExpired,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the screen is showing a file or a diff full screen, so the app's navigation should step aside. */
|
||||
fun isReadingFullScreen(codeBrowserState: CodeBrowserUiState, historyState: HistoryUiState): Boolean =
|
||||
codeBrowserState.isFileLevel || historyState.showsFileDiff
|
||||
|
||||
private const val FILES_PAGE = 0
|
||||
private const val COMMIT_PAGE = 1
|
||||
|
||||
@ -29,20 +29,19 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@ -65,37 +64,42 @@ import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import com.ubisam.ubgitea.domain.model.SourceContent
|
||||
import com.ubisam.ubgitea.domain.model.TreeEntry
|
||||
import com.ubisam.ubgitea.domain.model.TreeEntryType
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.CodeSyntaxColors
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.highlightLines
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
|
||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** True while the browser is showing one file (or failing to) rather than a directory listing. */
|
||||
val CodeBrowserUiState.isFileLevel: Boolean
|
||||
get() = when (this) {
|
||||
is CodeBrowserUiState.File, is CodeBrowserUiState.UnsupportedEntry -> true
|
||||
is CodeBrowserUiState.Loading -> isFile
|
||||
is CodeBrowserUiState.Error -> isFile
|
||||
CodeBrowserUiState.Idle, is CodeBrowserUiState.Directory -> false
|
||||
}
|
||||
|
||||
/** The Files tab: a directory listing with the path above it. A file opens in [FileViewerScreen]. */
|
||||
@Composable
|
||||
fun CodeBrowserScreen(
|
||||
state: CodeBrowserUiState,
|
||||
@ -104,118 +108,181 @@ fun CodeBrowserScreen(
|
||||
onRetry: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onAuthenticationExpired: () -> Unit,
|
||||
onNavigateImage: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
when (state) {
|
||||
CodeBrowserUiState.Idle -> Unit
|
||||
is CodeBrowserUiState.Loading -> {
|
||||
BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected)
|
||||
CenterMessage { CircularProgressIndicator() }
|
||||
}
|
||||
is CodeBrowserUiState.Directory -> {
|
||||
BrowserHeader(state.target, state.path, false, onBreadcrumbSelected)
|
||||
if (state.entries.isEmpty()) {
|
||||
CenterMessage { Text(stringResource(R.string.codebrowser_directory_empty)) }
|
||||
} else {
|
||||
LazyColumn(Modifier.weight(1f)) {
|
||||
items(state.entries, key = { it.path.value }) { entry ->
|
||||
EntryRow(entry, onEntrySelected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is CodeBrowserUiState.File -> {
|
||||
BrowserHeader(state.target, state.path, true, onBreadcrumbSelected)
|
||||
FileContent(state.content, state.path, state.siblingImagePaths, onNavigateImage)
|
||||
}
|
||||
is CodeBrowserUiState.UnsupportedEntry -> {
|
||||
BrowserHeader(state.target, state.path, true, onBreadcrumbSelected)
|
||||
CenterMessage {
|
||||
Text(
|
||||
stringResource(
|
||||
if (state.type == TreeEntryType.SUBMODULE) R.string.codebrowser_submodule
|
||||
else R.string.codebrowser_symlink,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
is CodeBrowserUiState.Error -> {
|
||||
BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected)
|
||||
CenterMessage {
|
||||
Text(stringResource(state.type.messageResource))
|
||||
if (state.type == CodeBrowserError.AUTHENTICATION_EXPIRED) {
|
||||
Button(onClick = onAuthenticationExpired) {
|
||||
Text(stringResource(R.string.codebrowser_sign_in_again))
|
||||
}
|
||||
} else {
|
||||
Button(onClick = onRetry) { Text(stringResource(R.string.codebrowser_retry)) }
|
||||
Column(modifier.fillMaxSize()) {
|
||||
when (state) {
|
||||
CodeBrowserUiState.Idle -> Unit
|
||||
is CodeBrowserUiState.Loading -> {
|
||||
Breadcrumbs(state.path, onBreadcrumbSelected)
|
||||
CenteredLoading(null)
|
||||
}
|
||||
is CodeBrowserUiState.Directory -> {
|
||||
Breadcrumbs(state.path, onBreadcrumbSelected)
|
||||
if (state.entries.isEmpty()) {
|
||||
CenteredMessage(stringResource(R.string.codebrowser_directory_empty))
|
||||
} else {
|
||||
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 16.dp)) {
|
||||
itemsIndexed(state.entries, key = { _, entry -> entry.path.value }) { index, entry ->
|
||||
EntryRow(entry, index == 0, index == state.entries.lastIndex, onEntrySelected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is CodeBrowserUiState.Error -> {
|
||||
Breadcrumbs(state.path, onBreadcrumbSelected)
|
||||
BrowserError(state.type, onRetry, onAuthenticationExpired)
|
||||
}
|
||||
// Shown by FileViewerScreen; the tab never receives these.
|
||||
is CodeBrowserUiState.File, is CodeBrowserUiState.UnsupportedEntry -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One file, full screen, outside the Files/Commit pager. Being outside it is the point: code that
|
||||
* scrolls sideways inside a pager that swipes sideways is what made TASK-048 take three attempts.
|
||||
*/
|
||||
@Composable
|
||||
private fun BrowserHeader(
|
||||
target: CodeBrowserTarget,
|
||||
path: RepositoryPath,
|
||||
leafIsFile: Boolean,
|
||||
onBreadcrumbSelected: (RepositoryPath) -> Unit,
|
||||
fun FileViewerScreen(
|
||||
state: CodeBrowserUiState,
|
||||
preferences: CodeViewerPreferences,
|
||||
onBack: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onAuthenticationExpired: () -> Unit,
|
||||
onNavigateImage: (Int) -> Unit,
|
||||
onSetWordWrap: (Boolean) -> Unit,
|
||||
onSetFontSize: (Int) -> Unit,
|
||||
onOpenSibling: (RepositoryPath, String?) -> Unit,
|
||||
onOpenEntry: (TreeEntry) -> Unit,
|
||||
onLoadCounterpart: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge)
|
||||
val refKind = when (target.kind) {
|
||||
CodeReferenceKind.BRANCH -> stringResource(R.string.codebrowser_ref_branch)
|
||||
CodeReferenceKind.TAG -> stringResource(R.string.codebrowser_ref_tag)
|
||||
CodeReferenceKind.COMMIT -> stringResource(R.string.codebrowser_ref_commit)
|
||||
}
|
||||
Text(stringResource(R.string.codebrowser_ref, refKind, target.ref.value))
|
||||
Row(Modifier.horizontalScroll(rememberScrollState()).padding(top = 12.dp)) {
|
||||
val breadcrumbs = CodeBrowserNavigation.breadcrumbs(path)
|
||||
breadcrumbs.forEachIndexed { index, crumb ->
|
||||
if (index > 0) Text(" / ", modifier = Modifier.padding(horizontal = 2.dp))
|
||||
val canNavigate = !(leafIsFile && index == breadcrumbs.lastIndex)
|
||||
Text(
|
||||
crumb.value.substringAfterLast('/').ifEmpty { stringResource(R.string.codebrowser_root) },
|
||||
color = if (canNavigate) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = if (canNavigate) {
|
||||
Modifier.clickable(role = Role.Button) { onBreadcrumbSelected(crumb) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
}
|
||||
if (state is CodeBrowserUiState.File && state.content is ContentClassification.Text) {
|
||||
CodeViewerScreen(
|
||||
state, state.content, preferences, onBack, onSetWordWrap, onSetFontSize, onOpenSibling, onOpenEntry,
|
||||
onLoadCounterpart, modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
BackHandler(onBack = onBack)
|
||||
val path = when (state) {
|
||||
is CodeBrowserUiState.File -> state.path
|
||||
is CodeBrowserUiState.Loading -> state.path
|
||||
is CodeBrowserUiState.Error -> state.path
|
||||
is CodeBrowserUiState.UnsupportedEntry -> state.path
|
||||
else -> RepositoryPath.Root
|
||||
}
|
||||
Column(modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).statusBarsPadding()) {
|
||||
ScreenTopBar(
|
||||
title = path.value.substringAfterLast('/'),
|
||||
subtitle = CodeBrowserNavigation.parent(path).value.ifEmpty { stringResource(R.string.codebrowser_root) },
|
||||
onBack = onBack,
|
||||
monospaceTitle = true,
|
||||
)
|
||||
when (state) {
|
||||
is CodeBrowserUiState.File -> FileContent(state.content, state.path, state.siblingImagePaths, onNavigateImage)
|
||||
is CodeBrowserUiState.Loading -> CenteredLoading(null)
|
||||
is CodeBrowserUiState.Error -> BrowserError(state.type, onRetry, onAuthenticationExpired)
|
||||
is CodeBrowserUiState.UnsupportedEntry -> CenteredMessage(
|
||||
stringResource(
|
||||
if (state.type == TreeEntryType.SUBMODULE) R.string.codebrowser_submodule else R.string.codebrowser_symlink,
|
||||
),
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntryRow(entry: TreeEntry, onSelected: (TreeEntry) -> Unit) {
|
||||
private fun BrowserError(type: CodeBrowserError, onRetry: () -> Unit, onAuthenticationExpired: () -> Unit) {
|
||||
val expired = type == CodeBrowserError.AUTHENTICATION_EXPIRED
|
||||
CenteredMessage(
|
||||
stringResource(type.messageResource),
|
||||
action = stringResource(if (expired) R.string.codebrowser_sign_in_again else R.string.codebrowser_retry),
|
||||
onAction = if (expired) onAuthenticationExpired else onRetry,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Breadcrumbs(path: RepositoryPath, onBreadcrumbSelected: (RepositoryPath) -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val breadcrumbs = CodeBrowserNavigation.breadcrumbs(path)
|
||||
breadcrumbs.forEachIndexed { index, crumb ->
|
||||
if (index > 0) {
|
||||
Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, modifier = Modifier.padding(horizontal = 6.dp))
|
||||
}
|
||||
val isLast = index == breadcrumbs.lastIndex
|
||||
Text(
|
||||
crumb.value.substringAfterLast('/').ifEmpty { stringResource(R.string.codebrowser_root) },
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isLast) FontWeight.Bold else FontWeight.Medium,
|
||||
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.then(if (!isLast) Modifier.clickable(role = Role.Button) { onBreadcrumbSelected(crumb) } else Modifier)
|
||||
.padding(vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntryRow(entry: TreeEntry, isFirst: Boolean, isLast: Boolean, onSelected: (TreeEntry) -> Unit) {
|
||||
val kind = when (entry.type) {
|
||||
TreeEntryType.DIRECTORY -> stringResource(R.string.codebrowser_entry_directory)
|
||||
TreeEntryType.FILE -> stringResource(R.string.codebrowser_entry_file)
|
||||
TreeEntryType.SYMLINK -> stringResource(R.string.codebrowser_entry_symlink)
|
||||
TreeEntryType.SUBMODULE -> stringResource(R.string.codebrowser_entry_submodule)
|
||||
}
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clickable(role = Role.Button) { onSelected(entry) }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(entry.name, style = MaterialTheme.typography.titleMedium)
|
||||
Text(kind, style = MaterialTheme.typography.bodySmall)
|
||||
val isDirectory = entry.type == TreeEntryType.DIRECTORY
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (isFirst) 20.dp else 0.dp,
|
||||
topEnd = if (isFirst) 20.dp else 0.dp,
|
||||
bottomStart = if (isLast) 20.dp else 0.dp,
|
||||
bottomEnd = if (isLast) 20.dp else 0.dp,
|
||||
)
|
||||
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable(role = Role.Button) { onSelected(entry) }
|
||||
.heightIn(min = 52.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
UBIcon(
|
||||
if (isDirectory) R.drawable.ic_ub_folder else R.drawable.ic_ub_file,
|
||||
kind,
|
||||
size = 20.dp,
|
||||
tint = if (isDirectory) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
entry.name,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!isDirectory && entry.type != TreeEntryType.FILE) {
|
||||
Text(kind, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else if (isDirectory) {
|
||||
UBIcon(R.drawable.ic_ub_chevron_right, null, size = 18.dp, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("DEPRECATION")
|
||||
private fun FileContent(
|
||||
content: ContentClassification,
|
||||
path: RepositoryPath,
|
||||
@ -223,31 +290,8 @@ private fun FileContent(
|
||||
onNavigateImage: (Int) -> Unit,
|
||||
) {
|
||||
when (content) {
|
||||
is ContentClassification.Text -> {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var wordWrap by remember(path) { mutableStateOf(false) }
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedButton(onClick = { clipboard.setText(AnnotatedString(content.renderedText)) }) {
|
||||
Text(stringResource(R.string.codebrowser_copy))
|
||||
}
|
||||
OutlinedButton(onClick = { wordWrap = !wordWrap }) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (wordWrap) R.string.codebrowser_word_wrap_off else R.string.codebrowser_word_wrap_on,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (content.isTruncated) Text(stringResource(R.string.codebrowser_text_truncated))
|
||||
}
|
||||
CodeBlock(
|
||||
text = content.renderedText,
|
||||
language = syntaxLanguageForPath(path),
|
||||
wordWrap = wordWrap,
|
||||
modifier = Modifier.fillMaxSize().padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Text is shown by CodeViewerScreen and never reaches here.
|
||||
is ContentClassification.Text -> Unit
|
||||
is ContentClassification.Image -> {
|
||||
val index = siblingImagePaths.indexOf(path)
|
||||
ImageContent(content, index, siblingImagePaths.size, onNavigateImage)
|
||||
@ -264,104 +308,6 @@ private fun FileContent(
|
||||
}
|
||||
}
|
||||
|
||||
// Line-numbered, syntax-highlighted source view (see SyntaxHighlighter.kt for what the highlighting
|
||||
// actually is/isn't).
|
||||
//
|
||||
// Four things this shape specifically works around, all found live on-device -- none of them
|
||||
// visible from build/lint/unit tests, which can't see a real touch gesture or a real wrapped-text
|
||||
// layout:
|
||||
// 1. An earlier version paired gutter+code in a LazyColumn with one horizontalScroll per row.
|
||||
// Horizontal drags simply did nothing -- most likely BranchDetailScreen's HorizontalPager
|
||||
// (userScrollEnabled is turned off for scrollable code, but apparently still not fully out of
|
||||
// the nested-scroll picture for a LazyColumn specifically, the way it is for a plain
|
||||
// Modifier.horizontalScroll chain -- see that file's own comment on this exact class of gesture
|
||||
// conflict) intercepting the same-orientation drag first. Switched the outer container from
|
||||
// LazyColumn to a plain Column + Modifier.verticalScroll, matching the shape of the file
|
||||
// viewer's original, already-known-working implementation -- confirmed live afterward that
|
||||
// horizontal drag actually moves the code again.
|
||||
// 2. A version in between paired every row's gutter+code into a single Row (fix for #3 below) but
|
||||
// gave each row's code Text its own independent Modifier.horizontalScroll -- reasoned at the
|
||||
// time to be visually invisible since every row's text starts at column 0. Wrong in practice:
|
||||
// dragging one line only scrolled that one line, leaving the rest of the file visually
|
||||
// unscrolled and out of step, which reads as broken (a real product report: "선택한 곳만 좌우
|
||||
// 스크롤 되는건 아닌 것 같다").
|
||||
// 3. Tried making every row share one hoisted horizontalScrollState instead. That made horizontal
|
||||
// drag do *nothing at all*, on any row, including the one being dragged -- multiple separate
|
||||
// Modifier.horizontalScroll gesture-detector regions apparently don't cleanly share one
|
||||
// ScrollableState the way a single shared scroll *region* does; each row still had its own
|
||||
// pointer-input node competing over the same state instead of there being one region to drag.
|
||||
// 4. **Final:** don't try to synchronize N separate horizontalScroll regions at all -- give the
|
||||
// *whole* code column exactly one horizontalScroll region, wrapping every line's Text stacked in
|
||||
// a single Column, with the gutter as a separate sibling Column that never scrolls horizontally.
|
||||
// This only works correctly (gutter height staying matched to code height) as long as no line
|
||||
// ever wraps to multiple visual rows, which is exactly true whenever this two-Column layout is
|
||||
// even in play: it's used only when word wrap is off (softWrap = false, so every line is exactly
|
||||
// one visual row no matter how long). When word wrap is on, wrapping can make a line take
|
||||
// multiple visual rows, so gutter+code go back to the Row-per-line pairing from fix 2 instead
|
||||
// (needed for correct number alignment, and horizontalScroll isn't used in this branch anyway --
|
||||
// wrapped lines have nothing to scroll to).
|
||||
//
|
||||
// Not lazy (every line is a real composable, not virtualized) -- the original single-Text version
|
||||
// wasn't virtualized either, and SourceContentClassifier already bounds how many lines a file view
|
||||
// ever receives, so this isn't a new performance ceiling.
|
||||
@Composable
|
||||
private fun CodeBlock(text: String, language: SyntaxLanguage, wordWrap: Boolean, modifier: Modifier = Modifier) {
|
||||
val colors = rememberCodeSyntaxColors()
|
||||
val lines = remember(text) { text.split("\n") }
|
||||
// Whole-file, once per file: block comments carry state across lines (see tokenizeLines).
|
||||
val highlighted = remember(lines, language, colors) { highlightLines(lines, language, colors) }
|
||||
val lineNumberWidth = remember(lines.size) { lines.size.toString().length }
|
||||
val gutterWidth = (lineNumberWidth * 8 + 12).dp
|
||||
SelectionContainer(modifier = modifier) {
|
||||
if (wordWrap) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
highlighted.forEachIndexed { index, line ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
LineNumber(index, gutterWidth, colors)
|
||||
Text(
|
||||
text = line,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
color = colors.text,
|
||||
softWrap = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
Column {
|
||||
lines.indices.forEach { index -> LineNumber(index, gutterWidth, colors) }
|
||||
}
|
||||
Column(Modifier.horizontalScroll(rememberScrollState())) {
|
||||
highlighted.forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
color = colors.text,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LineNumber(index: Int, width: Dp, colors: CodeSyntaxColors) {
|
||||
Text(
|
||||
text = (index + 1).toString(),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
color = colors.comment,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.width(width).padding(end = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// currentIndex is -1 when this file isn't part of a sibling image gallery (e.g. opened directly
|
||||
// via a retry rather than from a directory listing) — swipe navigation is simply unavailable then,
|
||||
// same as any single-image folder (totalCount <= 1).
|
||||
|
||||
@ -18,6 +18,7 @@ import com.ubisam.ubgitea.domain.model.TreeEntryType
|
||||
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.SourceContentClassifier
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.counterpartOf
|
||||
import java.util.Collections
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@ -39,6 +40,14 @@ enum class CodeReferenceKind { BRANCH, TAG, COMMIT }
|
||||
|
||||
enum class CodeBrowserError { OFFLINE, AUTHENTICATION_EXPIRED, NOT_FOUND, PERMISSION_DENIED, SERVER }
|
||||
|
||||
/** The other half of a header/source pair, fetched only when a window wide enough to show both asks for it. */
|
||||
sealed interface CounterpartUiState {
|
||||
data object NotLoaded : CounterpartUiState
|
||||
data object Loading : CounterpartUiState
|
||||
data class Loaded(val content: ContentClassification) : CounterpartUiState
|
||||
data object Failed : CounterpartUiState
|
||||
}
|
||||
|
||||
sealed interface CodeBrowserUiState {
|
||||
data object Idle : CodeBrowserUiState
|
||||
data class Loading(
|
||||
@ -62,6 +71,16 @@ sealed interface CodeBrowserUiState {
|
||||
// second directory fetch. Empty for anything opened outside a directory listing (e.g. a
|
||||
// retry from Error) or when the containing folder has no other images.
|
||||
val siblingImagePaths: List<RepositoryPath> = emptyList(),
|
||||
// Everything in the directory this file was opened from, in listing order: what a wide
|
||||
// window's file pane shows beside the code, and where a C/C++ file's counterpart is
|
||||
// looked for. Empty when the file was not opened from a directory listing.
|
||||
val siblingEntries: List<TreeEntry> = emptyList(),
|
||||
// The header of this source file or the source of this header, when the directory has one.
|
||||
val counterpartPath: RepositoryPath? = null,
|
||||
val counterpart: CounterpartUiState = CounterpartUiState.NotLoaded,
|
||||
// A symbol to bring into view once the file is shown: set when the file was reached by
|
||||
// following a symbol from its counterpart.
|
||||
val focusSymbol: String? = null,
|
||||
) : CodeBrowserUiState
|
||||
data class UnsupportedEntry(
|
||||
val target: CodeBrowserTarget,
|
||||
@ -93,6 +112,7 @@ class CodeBrowserViewModel(
|
||||
private val mutableState = MutableStateFlow<CodeBrowserUiState>(CodeBrowserUiState.Idle)
|
||||
val state: StateFlow<CodeBrowserUiState> = mutableState.asStateFlow()
|
||||
private var operation: Job? = null
|
||||
private var counterpartOperation: Job? = null
|
||||
|
||||
fun open(target: CodeBrowserTarget) = loadDirectory(target, RepositoryPath.Root)
|
||||
|
||||
@ -100,14 +120,7 @@ class CodeBrowserViewModel(
|
||||
val target = currentTarget() ?: return
|
||||
when (entry.type) {
|
||||
TreeEntryType.DIRECTORY -> loadDirectory(target, entry.path)
|
||||
TreeEntryType.FILE -> {
|
||||
val siblingImagePaths = (mutableState.value as? CodeBrowserUiState.Directory)
|
||||
?.entries
|
||||
?.filter { it.type == TreeEntryType.FILE && it.path.hasImageExtension() }
|
||||
?.map { it.path }
|
||||
?: emptyList()
|
||||
loadFile(target, entry.path, siblingImagePaths)
|
||||
}
|
||||
TreeEntryType.FILE -> loadFile(target, entry.path, currentSiblingEntries())
|
||||
TreeEntryType.SYMLINK, TreeEntryType.SUBMODULE -> {
|
||||
operation?.cancel()
|
||||
mutableState.value = CodeBrowserUiState.UnsupportedEntry(target, entry.path, entry.type)
|
||||
@ -124,7 +137,38 @@ class CodeBrowserViewModel(
|
||||
if (index == -1) return
|
||||
val newIndex = index + delta
|
||||
if (newIndex !in siblings.indices) return
|
||||
loadFile(current.target, siblings[newIndex], siblings)
|
||||
loadFile(current.target, siblings[newIndex], current.siblingEntries)
|
||||
}
|
||||
|
||||
/** Opens another file of the directory the current file came from, optionally at a symbol. */
|
||||
fun openSibling(path: RepositoryPath, focusSymbol: String? = null) {
|
||||
val current = mutableState.value as? CodeBrowserUiState.File ?: return
|
||||
if (path == current.path) return
|
||||
loadFile(current.target, path, current.siblingEntries, focusSymbol)
|
||||
}
|
||||
|
||||
/** Fetches the current file's counterpart so both can be shown side by side. */
|
||||
fun loadCounterpart() {
|
||||
val current = mutableState.value as? CodeBrowserUiState.File ?: return
|
||||
val path = current.counterpartPath ?: return
|
||||
if (current.counterpart is CounterpartUiState.Loading || current.counterpart is CounterpartUiState.Loaded) return
|
||||
mutableState.value = current.copy(counterpart = CounterpartUiState.Loading)
|
||||
counterpartOperation?.cancel()
|
||||
counterpartOperation = viewModelScope.launch {
|
||||
val result = try {
|
||||
val classification = withContext(defaultDispatcher) {
|
||||
classifier.classify(browser.getFile(current.target.repository, current.target.ref, path).content, path)
|
||||
}
|
||||
CounterpartUiState.Loaded(classification)
|
||||
} catch (failure: Throwable) {
|
||||
if (failure is CancellationException) throw failure
|
||||
CounterpartUiState.Failed
|
||||
}
|
||||
val latest = mutableState.value as? CodeBrowserUiState.File ?: return@launch
|
||||
if (latest.path == current.path && latest.counterpartPath == path) {
|
||||
mutableState.value = latest.copy(counterpart = result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openBreadcrumb(path: RepositoryPath) {
|
||||
@ -150,9 +194,16 @@ class CodeBrowserViewModel(
|
||||
fun close() {
|
||||
operation?.cancel()
|
||||
operation = null
|
||||
counterpartOperation?.cancel()
|
||||
mutableState.value = CodeBrowserUiState.Idle
|
||||
}
|
||||
|
||||
private fun currentSiblingEntries(): List<TreeEntry> = when (val current = mutableState.value) {
|
||||
is CodeBrowserUiState.Directory -> current.entries
|
||||
is CodeBrowserUiState.File -> current.siblingEntries
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
// Network parsing, directory sort/dedupe, and file classification are CPU/allocation-heavy
|
||||
// enough on large trees or large files that they're moved off viewModelScope's Main dispatcher
|
||||
// here, rather than left to run on the UI thread after the network suspend point resumes.
|
||||
@ -174,15 +225,26 @@ class CodeBrowserViewModel(
|
||||
private fun loadFile(
|
||||
target: CodeBrowserTarget,
|
||||
path: RepositoryPath,
|
||||
siblingImagePaths: List<RepositoryPath> = emptyList(),
|
||||
siblingEntries: List<TreeEntry> = emptyList(),
|
||||
focusSymbol: String? = null,
|
||||
) = startOperation {
|
||||
counterpartOperation?.cancel()
|
||||
mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = true)
|
||||
try {
|
||||
val classification = withContext(defaultDispatcher) {
|
||||
val file = browser.getFile(target.repository, target.ref, path)
|
||||
classifier.classify(file.content, path)
|
||||
}
|
||||
mutableState.value = CodeBrowserUiState.File(target, path, classification, siblingImagePaths)
|
||||
val siblingFiles = siblingEntries.filter { it.type == TreeEntryType.FILE }.map(TreeEntry::path)
|
||||
mutableState.value = CodeBrowserUiState.File(
|
||||
target = target,
|
||||
path = path,
|
||||
content = classification,
|
||||
siblingImagePaths = siblingFiles.filter { it.hasImageExtension() },
|
||||
siblingEntries = siblingEntries,
|
||||
counterpartPath = counterpartOf(path, siblingFiles),
|
||||
focusSymbol = focusSymbol,
|
||||
)
|
||||
} catch (failure: Throwable) {
|
||||
handleFailure(target, path, failure, isFile = true)
|
||||
}
|
||||
|
||||
@ -0,0 +1,267 @@
|
||||
package com.ubisam.ubgitea.feature.codebrowser
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.rememberScrollableState
|
||||
import androidx.compose.foundation.gestures.scrollable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.text.selection.DisableSelection
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextIndent
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.CodeSymbol
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.SearchMatch
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.extractSymbols
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.highlightLines
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** One file as the viewer needs it: its lines, their colors, and its outline. */
|
||||
@Immutable
|
||||
class CodeDocument(
|
||||
val path: RepositoryPath,
|
||||
val language: SyntaxLanguage,
|
||||
val lines: List<String>,
|
||||
val highlighted: List<AnnotatedString>,
|
||||
val symbols: List<CodeSymbol>,
|
||||
val longestLineLength: Int,
|
||||
) {
|
||||
val name: String get() = path.value.substringAfterLast('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits and colors [text] off the main thread. Until that finishes the document shows its lines
|
||||
* uncolored and without an outline, so a large file appears at once and gains color a moment later
|
||||
* rather than holding the screen blank.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberCodeDocument(path: RepositoryPath, text: String): CodeDocument {
|
||||
val colors = rememberCodeSyntaxColors()
|
||||
val language = remember(path) { syntaxLanguageForPath(path) }
|
||||
val lines = remember(text) { text.split("\n") }
|
||||
val plain = remember(path, lines) {
|
||||
CodeDocument(path, language, lines, lines.map(::AnnotatedString), emptyList(), lines.maxOfOrNull(String::length) ?: 0)
|
||||
}
|
||||
val document by produceState(plain, plain, colors) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
CodeDocument(
|
||||
path = path,
|
||||
language = language,
|
||||
lines = lines,
|
||||
highlighted = highlightLines(lines, language, colors),
|
||||
symbols = extractSymbols(lines, language),
|
||||
longestLineLength = plain.longestLineLength,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Until the producer has caught up with a newly opened file, its value is still the previous
|
||||
// file's document.
|
||||
return if (document.path == path && document.lines === lines) document else plain
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class SearchHighlight(val byLine: Map<Int, List<SearchMatch>>, val current: SearchMatch?) {
|
||||
companion object {
|
||||
val None = SearchHighlight(emptyMap(), null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The code itself: numbered, colored lines in a lazily composed list, so only the lines on screen
|
||||
* cost anything. With wrapping on, a long line continues on the next row, indented past the line's
|
||||
* own indentation so the structure still reads. With wrapping off, every line is one row and the
|
||||
* whole pane scrolls sideways together.
|
||||
*
|
||||
* Sideways scrolling is one hand-driven offset shared by every row, not a scroll modifier per row:
|
||||
* rows that each own a scroll modifier scroll one at a time (TASK-048). This is the approach the
|
||||
* commit diff and the CSV table already use.
|
||||
*/
|
||||
@Composable
|
||||
fun CodePane(
|
||||
document: CodeDocument,
|
||||
listState: LazyListState,
|
||||
wordWrap: Boolean,
|
||||
fontSizeSp: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
search: SearchHighlight = SearchHighlight.None,
|
||||
focusedLine: Int? = null,
|
||||
contentPadding: PaddingValues = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
val colors = rememberCodeSyntaxColors()
|
||||
val style = remember(fontSizeSp, colors.text) {
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = fontSizeSp.sp,
|
||||
lineHeight = (fontSizeSp * LINE_HEIGHT_FACTOR).sp,
|
||||
color = colors.text,
|
||||
)
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val measurer = rememberTextMeasurer()
|
||||
val characterWidthPx = remember(style) { measurer.measure("0", style).size.width.toFloat() }
|
||||
val digits = document.lines.size.toString().length
|
||||
val gutterWidth = with(density) { (characterWidthPx * digits).toDp() } + GutterPadding
|
||||
val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val focusColor = MaterialTheme.colorScheme.primaryContainer
|
||||
val hit = searchHitColors()
|
||||
|
||||
var viewportWidthPx by remember { mutableIntStateOf(0) }
|
||||
val gutterWidthPx = with(density) { gutterWidth.toPx() }
|
||||
val maxOffsetPx = ((document.longestLineLength + 2) * characterWidthPx - (viewportWidthPx - gutterWidthPx)).coerceAtLeast(0f)
|
||||
val offsetPx = remember(document.path, wordWrap) { mutableFloatStateOf(0f) }
|
||||
val sideways = rememberScrollableState { delta ->
|
||||
val previous = offsetPx.floatValue
|
||||
val next = (previous - delta).coerceIn(0f, maxOffsetPx)
|
||||
offsetPx.floatValue = next
|
||||
previous - next
|
||||
}
|
||||
|
||||
SelectionContainer(modifier.fillMaxSize().onSizeChanged { viewportWidthPx = it.width }) {
|
||||
LazyColumn(state = listState, contentPadding = contentPadding, modifier = Modifier.fillMaxSize()) {
|
||||
items(document.lines.size, key = { it }) { index ->
|
||||
val line = document.highlighted.getOrElse(index) { AnnotatedString(document.lines[index]) }
|
||||
val shown = remember(line, search.byLine[index], search.current) {
|
||||
withSearchHits(line, search.byLine[index], search.current, hit)
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().background(if (index == focusedLine) focusColor else Color.Transparent),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
DisableSelection {
|
||||
Text(
|
||||
(index + 1).toString(),
|
||||
style = style,
|
||||
color = gutterColor,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.width(gutterWidth).padding(end = GutterGap),
|
||||
)
|
||||
}
|
||||
if (wordWrap) {
|
||||
val indent = document.lines[index].indentWidth()
|
||||
val hanging = with(density) { (characterWidthPx * (indent + CONTINUATION_INDENT)).toSp() }
|
||||
Text(
|
||||
shown,
|
||||
style = style.copy(textIndent = TextIndent(restLine = hanging)),
|
||||
softWrap = true,
|
||||
modifier = Modifier.weight(1f).padding(end = 16.dp),
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.weight(1f).clipToBounds().scrollable(sideways, Orientation.Horizontal)) {
|
||||
Text(
|
||||
shown,
|
||||
style = style,
|
||||
softWrap = false,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(-offsetPx.floatValue.roundToInt(), 0) }
|
||||
.wrapContentWidth(Alignment.Start, unbounded = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The zero-based line at the top of the pane: what the "you are here" strip is about. */
|
||||
@Composable
|
||||
fun rememberFirstVisibleLine(listState: LazyListState): State<Int> =
|
||||
remember(listState) { derivedStateOf { listState.firstVisibleItemIndex } }
|
||||
|
||||
private class SearchHitColors(val other: Color, val current: Color, val onCurrent: Color)
|
||||
|
||||
@Composable
|
||||
private fun searchHitColors(): SearchHitColors {
|
||||
val dark = isSystemInDarkTheme()
|
||||
return remember(dark) {
|
||||
if (dark) {
|
||||
SearchHitColors(Color(0xFF5A4713), Color(0xFFFFB84D), Color(0xFF1A1200))
|
||||
} else {
|
||||
SearchHitColors(Color(0xFFFFE9A8), Color(0xFFFFB84D), Color(0xFF0E1B2C))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun withSearchHits(
|
||||
line: AnnotatedString,
|
||||
matches: List<SearchMatch>?,
|
||||
current: SearchMatch?,
|
||||
colors: SearchHitColors,
|
||||
): AnnotatedString {
|
||||
if (matches.isNullOrEmpty()) return line
|
||||
return buildAnnotatedString {
|
||||
append(line)
|
||||
matches.forEach { match ->
|
||||
if (match.end > line.length) return@forEach
|
||||
val style = if (match == current) {
|
||||
SpanStyle(background = colors.current, color = colors.onCurrent)
|
||||
} else {
|
||||
SpanStyle(background = colors.other)
|
||||
}
|
||||
addStyle(style, match.start, match.end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.indentWidth(): Int {
|
||||
var width = 0
|
||||
for (character in this) {
|
||||
when (character) {
|
||||
' ' -> width++
|
||||
'\t' -> width += TAB_WIDTH
|
||||
else -> return width
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
private const val LINE_HEIGHT_FACTOR = 1.5f
|
||||
private const val CONTINUATION_INDENT = 4
|
||||
private const val TAB_WIDTH = 4
|
||||
private val GutterPadding = 24.dp
|
||||
private val GutterGap = 10.dp
|
||||
@ -0,0 +1,998 @@
|
||||
package com.ubisam.ubgitea.feature.codebrowser
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import com.ubisam.ubgitea.domain.model.TreeEntry
|
||||
import com.ubisam.ubgitea.domain.model.TreeEntryType
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.CodeSymbol
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.CppFileRole
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.SymbolKind
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.cppRoleOf
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.enclosingSymbols
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.findMatches
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.findSymbolNamed
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.firstMatchFrom
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.parseLineNumber
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
||||
import com.ubisam.ubgitea.ui.components.CautionBanner
|
||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.MonoChip
|
||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||
import com.ubisam.ubgitea.ui.components.ActivityTone
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import com.ubisam.ubgitea.ui.shell.WideLayoutBreakpoint
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class CodeViewerPreferences(val wordWrap: Boolean, val fontSizeSp: Int)
|
||||
|
||||
/** The window shapes the viewer lays itself out for. */
|
||||
private enum class ViewerLayout {
|
||||
/** A phone held upright: one pane, tools along the bottom within thumb reach. */
|
||||
PHONE,
|
||||
|
||||
/** A phone on its side: wide but short, so every row of chrome is spent on code instead. */
|
||||
LANDSCAPE,
|
||||
|
||||
/** A tablet or an unfolded Z Fold: room for a second pane beside the code. */
|
||||
WIDE,
|
||||
}
|
||||
|
||||
private val CompactHeightBreakpoint = 480.dp
|
||||
private val SidePaneWidth = 268.dp
|
||||
private val NarrowSidePaneWidth = 220.dp
|
||||
private val NarrowWideBreakpoint = 700.dp
|
||||
|
||||
/**
|
||||
* Source code, full screen. Reading code on a phone is mostly finding the part that matters, so
|
||||
* the screen is built around getting there: search, an outline, a strip that says which function
|
||||
* the top line belongs to, and -- for a C/C++ header or source -- its counterpart one tap away, or
|
||||
* beside it when the window is wide enough.
|
||||
*/
|
||||
@Composable
|
||||
fun CodeViewerScreen(
|
||||
state: CodeBrowserUiState.File,
|
||||
text: ContentClassification.Text,
|
||||
preferences: CodeViewerPreferences,
|
||||
onBack: () -> Unit,
|
||||
onSetWordWrap: (Boolean) -> Unit,
|
||||
onSetFontSize: (Int) -> Unit,
|
||||
onOpenSibling: (RepositoryPath, String?) -> Unit,
|
||||
onOpenEntry: (TreeEntry) -> Unit,
|
||||
onLoadCounterpart: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val document = rememberCodeDocument(state.path, text.renderedText)
|
||||
val listState = remember(state.path) { LazyListState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val firstVisibleLine by rememberFirstVisibleLine(listState)
|
||||
|
||||
var searching by rememberSaveable(state.path.value) { mutableStateOf(false) }
|
||||
var query by rememberSaveable(state.path.value) { mutableStateOf("") }
|
||||
var currentMatch by remember(state.path, query) { mutableIntStateOf(0) }
|
||||
val matches = remember(document.lines, query) { findMatches(document.lines, query) }
|
||||
val highlight = remember(matches, currentMatch) {
|
||||
if (matches.isEmpty()) SearchHighlight.None else SearchHighlight(matches.groupBy { it.line }, matches.getOrNull(currentMatch))
|
||||
}
|
||||
var outlineOpen by remember(state.path) { mutableStateOf(false) }
|
||||
var focusedLine by remember(state.path) { mutableStateOf<Int?>(null) }
|
||||
var sideBySide by rememberSaveable { mutableStateOf(true) }
|
||||
var syncPanes by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
fun goToLine(line: Int) {
|
||||
focusedLine = line
|
||||
scope.launch { listState.scrollToItem(line.coerceIn(0, (document.lines.size - 1).coerceAtLeast(0))) }
|
||||
}
|
||||
fun goToMatch(index: Int) {
|
||||
if (matches.isEmpty()) return
|
||||
currentMatch = index.mod(matches.size)
|
||||
val line = matches[currentMatch].line
|
||||
scope.launch { listState.scrollToItem((line - SEARCH_CONTEXT_LINES).coerceAtLeast(0)) }
|
||||
}
|
||||
|
||||
// A search starts at what is on screen, not at the top of the file.
|
||||
LaunchedEffect(query, matches) {
|
||||
if (matches.isNotEmpty()) goToMatch(firstMatchFrom(matches, listState.firstVisibleItemIndex))
|
||||
}
|
||||
// Arriving by following a symbol from the counterpart file: bring that symbol into view.
|
||||
LaunchedEffect(state.path, state.focusSymbol, document.symbols) {
|
||||
val name = state.focusSymbol ?: return@LaunchedEffect
|
||||
findSymbolNamed(document.symbols, name)?.let { goToLine(it.line) }
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
outlineOpen -> outlineOpen = false
|
||||
searching -> {
|
||||
searching = false
|
||||
query = ""
|
||||
}
|
||||
else -> onBack()
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).statusBarsPadding().imePadding()) {
|
||||
val layout = when {
|
||||
maxHeight < CompactHeightBreakpoint -> ViewerLayout.LANDSCAPE
|
||||
maxWidth >= WideLayoutBreakpoint -> ViewerLayout.WIDE
|
||||
else -> ViewerLayout.PHONE
|
||||
}
|
||||
val sidePaneWidth = if (maxWidth < NarrowWideBreakpoint) NarrowSidePaneWidth else SidePaneWidth
|
||||
val counterpartPath = state.counterpartPath
|
||||
val showsPair = layout == ViewerLayout.WIDE && counterpartPath != null && sideBySide
|
||||
if (showsPair) LaunchedEffect(state.path, counterpartPath) { onLoadCounterpart() }
|
||||
|
||||
val tools = ViewerTools(
|
||||
preferences = preferences,
|
||||
searching = searching,
|
||||
canPair = layout == ViewerLayout.WIDE && counterpartPath != null,
|
||||
sideBySide = sideBySide,
|
||||
syncPanes = syncPanes,
|
||||
onSearch = { searching = true },
|
||||
onOutline = { outlineOpen = true },
|
||||
onToggleWrap = { onSetWordWrap(!preferences.wordWrap) },
|
||||
onSetFontSize = onSetFontSize,
|
||||
onToggleSideBySide = { sideBySide = !sideBySide },
|
||||
onToggleSync = { syncPanes = !syncPanes },
|
||||
text = text.renderedText,
|
||||
)
|
||||
val location = stringResource(
|
||||
R.string.codeviewer_location,
|
||||
state.target.repository.name,
|
||||
CodeBrowserNavigation.parent(state.path).value.ifEmpty { stringResource(R.string.codebrowser_root) },
|
||||
)
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
when {
|
||||
searching -> SearchBar(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
position = if (matches.isEmpty()) null else currentMatch + 1,
|
||||
total = matches.size,
|
||||
onPrevious = { goToMatch(currentMatch - 1) },
|
||||
onNext = { goToMatch(currentMatch + 1) },
|
||||
onClose = {
|
||||
searching = false
|
||||
query = ""
|
||||
},
|
||||
)
|
||||
layout == ViewerLayout.LANDSCAPE -> LandscapeBar(document, firstVisibleLine, onBack, tools)
|
||||
else -> ScreenTopBar(
|
||||
title = if (showsPair) document.name.substringBeforeLast('.') else document.name,
|
||||
subtitle = location,
|
||||
onBack = onBack,
|
||||
monospaceTitle = true,
|
||||
)
|
||||
}
|
||||
if (text.isTruncated) CautionBanner(stringResource(R.string.codebrowser_text_truncated))
|
||||
if (layout == ViewerLayout.PHONE && counterpartPath != null && !searching) {
|
||||
PairSwitch(state.path, counterpartPath) { onOpenSibling(counterpartPath, null) }
|
||||
}
|
||||
if (layout != ViewerLayout.LANDSCAPE && !showsPair) {
|
||||
FileInfoStrip(state, document)
|
||||
LocationStrip(enclosingSymbols(document.symbols, firstVisibleLine))
|
||||
}
|
||||
|
||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when {
|
||||
showsPair -> PairPanes(
|
||||
state = state,
|
||||
document = document,
|
||||
listState = listState,
|
||||
preferences = preferences,
|
||||
highlight = highlight,
|
||||
focusedLine = focusedLine,
|
||||
syncPanes = syncPanes,
|
||||
counterpartPath = counterpartPath!!,
|
||||
)
|
||||
else -> {
|
||||
if (layout == ViewerLayout.WIDE) {
|
||||
SidePane(
|
||||
state = state,
|
||||
document = document,
|
||||
currentLine = firstVisibleLine,
|
||||
onOpenFile = { onOpenSibling(it, null) },
|
||||
onOpenEntry = onOpenEntry,
|
||||
onSymbolSelected = { goToLine(it.line) },
|
||||
modifier = Modifier.width(sidePaneWidth).fillMaxHeight(),
|
||||
)
|
||||
VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||
CodePane(
|
||||
document = document,
|
||||
listState = listState,
|
||||
wordWrap = preferences.wordWrap,
|
||||
fontSizeSp = preferences.fontSizeSp,
|
||||
search = highlight,
|
||||
focusedLine = focusedLine,
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = if (counterpartPath != null) 76.dp else 8.dp),
|
||||
)
|
||||
if (counterpartPath != null && layout != ViewerLayout.WIDE && !searching) {
|
||||
val method = enclosingSymbols(document.symbols, firstVisibleLine).lastOrNull { it.kind == SymbolKind.METHOD }
|
||||
if (method != null) {
|
||||
JumpToCounterpart(
|
||||
counterpartPath,
|
||||
Modifier.align(Alignment.BottomCenter).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) { onOpenSibling(counterpartPath, method.name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (layout != ViewerLayout.LANDSCAPE) Toolbar(tools)
|
||||
}
|
||||
}
|
||||
|
||||
if (outlineOpen) {
|
||||
OutlineSheet(
|
||||
document = document,
|
||||
currentLine = firstVisibleLine,
|
||||
onSymbolSelected = {
|
||||
outlineOpen = false
|
||||
goToLine(it.line)
|
||||
},
|
||||
onLineEntered = {
|
||||
outlineOpen = false
|
||||
goToLine(it - 1)
|
||||
},
|
||||
onDismiss = { outlineOpen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewerTools(
|
||||
val preferences: CodeViewerPreferences,
|
||||
val searching: Boolean,
|
||||
val canPair: Boolean,
|
||||
val sideBySide: Boolean,
|
||||
val syncPanes: Boolean,
|
||||
val onSearch: () -> Unit,
|
||||
val onOutline: () -> Unit,
|
||||
val onToggleWrap: () -> Unit,
|
||||
val onSetFontSize: (Int) -> Unit,
|
||||
val onToggleSideBySide: () -> Unit,
|
||||
val onToggleSync: () -> Unit,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun Toolbar(tools: ViewerTools) {
|
||||
val label = stringResource(R.string.codeviewer_tools)
|
||||
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||
Column(Modifier.navigationBarsPadding()) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().semantics { contentDescription = label }.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
ToolButton(R.drawable.ic_ub_search, R.string.codeviewer_tool_search, tools.searching, Modifier.weight(1f), onClick = tools.onSearch)
|
||||
ToolButton(R.drawable.ic_ub_list, R.string.codeviewer_tool_outline, false, Modifier.weight(1f), onClick = tools.onOutline)
|
||||
ToolButton(
|
||||
R.drawable.ic_ub_wrap, R.string.codeviewer_tool_wrap, tools.preferences.wordWrap, Modifier.weight(1f),
|
||||
isToggle = true, onClick = tools.onToggleWrap,
|
||||
)
|
||||
FontSizeButton(tools, Modifier.weight(1f)) { onOpen ->
|
||||
ToolButton(R.drawable.ic_ub_text_size, R.string.codeviewer_tool_font_size, false, onClick = onOpen)
|
||||
}
|
||||
CopyButton(tools.text, Modifier.weight(1f)) { onCopy ->
|
||||
ToolButton(R.drawable.ic_ub_copy, R.string.codeviewer_tool_copy, false, onClick = onCopy)
|
||||
}
|
||||
if (tools.canPair) {
|
||||
ToolButton(
|
||||
R.drawable.ic_ub_split, R.string.codeviewer_tool_side_by_side, tools.sideBySide, Modifier.weight(1f),
|
||||
isToggle = true, onClick = tools.onToggleSideBySide,
|
||||
)
|
||||
if (tools.sideBySide) {
|
||||
ToolButton(
|
||||
R.drawable.ic_ub_unfold, R.string.codeviewer_tool_sync, tools.syncPanes, Modifier.weight(1f),
|
||||
isToggle = true, onClick = tools.onToggleSync,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolButton(
|
||||
@DrawableRes icon: Int,
|
||||
@StringRes label: Int,
|
||||
active: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
isToggle: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val on = stringResource(R.string.codeviewer_state_on)
|
||||
val off = stringResource(R.string.codeviewer_state_off)
|
||||
Column(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable(role = if (isToggle) Role.Switch else Role.Button, onClick = onClick)
|
||||
.semantics(mergeDescendants = true) { if (isToggle) stateDescription = if (active) on else off }
|
||||
.heightIn(min = 52.dp)
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(width = 52.dp, height = 30.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
UBIcon(
|
||||
icon,
|
||||
null,
|
||||
size = 22.dp,
|
||||
tint = if (active) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(label),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (active) FontWeight.Bold else FontWeight.Medium,
|
||||
color = if (active) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FontSizeButton(tools: ViewerTools, modifier: Modifier = Modifier, button: @Composable (onOpen: () -> Unit) -> Unit) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
button { open = true }
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
AppSettingsStore.CODE_FONT_SIZES_SP.forEach { size ->
|
||||
val selected = size == tools.preferences.fontSizeSp
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(R.string.codeviewer_font_size_option, size),
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
if (selected) UBIcon(R.drawable.ic_ub_check, null, size = 20.dp) else Spacer(Modifier.size(20.dp))
|
||||
},
|
||||
onClick = {
|
||||
open = false
|
||||
tools.onSetFontSize(size)
|
||||
},
|
||||
modifier = Modifier.semantics { this.selected = selected },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("DEPRECATION")
|
||||
private fun CopyButton(text: String, modifier: Modifier = Modifier, button: @Composable (onCopy: () -> Unit) -> Unit) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
button { clipboard.setText(AnnotatedString(text)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Landscape: back, file name, where the top line is, and the tools as icons -- all on one row. */
|
||||
@Composable
|
||||
private fun LandscapeBar(document: CodeDocument, currentLine: Int, onBack: () -> Unit, tools: ViewerTools) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||
Column {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) { UBIcon(R.drawable.ic_ub_back, stringResource(R.string.shell_back)) }
|
||||
Text(
|
||||
document.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(end = 8.dp).semantics { heading() },
|
||||
)
|
||||
Box(Modifier.weight(1f)) {
|
||||
LocationStrip(enclosingSymbols(document.symbols, currentLine), inline = true)
|
||||
}
|
||||
IconButton(onClick = tools.onSearch) { UBIcon(R.drawable.ic_ub_search, stringResource(R.string.codeviewer_tool_search)) }
|
||||
IconButton(onClick = tools.onOutline) { UBIcon(R.drawable.ic_ub_list, stringResource(R.string.codeviewer_tool_outline)) }
|
||||
IconButton(onClick = tools.onToggleWrap) {
|
||||
UBIcon(
|
||||
R.drawable.ic_ub_wrap,
|
||||
stringResource(R.string.codeviewer_tool_wrap),
|
||||
tint = if (tools.preferences.wordWrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
FontSizeButton(tools) { onOpen ->
|
||||
IconButton(onClick = onOpen) { UBIcon(R.drawable.ic_ub_text_size, stringResource(R.string.codeviewer_tool_font_size)) }
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
position: Int?,
|
||||
total: Int,
|
||||
onPrevious: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||
Column {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onClose) { UBIcon(R.drawable.ic_ub_back, stringResource(R.string.codeviewer_search_close)) }
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
placeholder = { Text(stringResource(R.string.codeviewer_search_hint)) },
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace),
|
||||
suffix = {
|
||||
if (query.isNotEmpty()) {
|
||||
Text(
|
||||
if (position == null) {
|
||||
stringResource(R.string.codeviewer_search_none)
|
||||
} else {
|
||||
stringResource(R.string.codeviewer_search_count, position, total)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { onNext() }),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
modifier = Modifier.weight(1f).focusRequester(focus),
|
||||
)
|
||||
IconButton(onClick = onPrevious, enabled = total > 0) {
|
||||
UBIcon(R.drawable.ic_ub_chevron_up, stringResource(R.string.codeviewer_search_previous))
|
||||
}
|
||||
IconButton(onClick = onNext, enabled = total > 0) {
|
||||
UBIcon(R.drawable.ic_ub_chevron_down, stringResource(R.string.codeviewer_search_next))
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileInfoStrip(state: CodeBrowserUiState.File, document: CodeDocument) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
MonoChip(
|
||||
state.target.ref.value,
|
||||
icon = if (state.target.kind == CodeReferenceKind.TAG) R.drawable.ic_ub_tag else R.drawable.ic_ub_branch,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.codeviewer_line_count, document.lines.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "You are here": the namespace, type and method the top line of the pane sits inside. */
|
||||
@Composable
|
||||
private fun LocationStrip(symbols: List<CodeSymbol>, inline: Boolean = false) {
|
||||
if (symbols.isEmpty()) return
|
||||
val label = stringResource(R.string.codeviewer_current_location)
|
||||
Row(
|
||||
Modifier
|
||||
.then(if (inline) Modifier.clip(CircleShape) else Modifier.fillMaxWidth())
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.semantics(mergeDescendants = true) { contentDescription = label + " " + symbols.joinToString(" ") { it.label } }
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = if (inline) 10.dp else 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
symbols.forEachIndexed { index, symbol ->
|
||||
if (index > 0) UBIcon(R.drawable.ic_ub_chevron_right, null, size = 12.dp, tint = MaterialTheme.colorScheme.onPrimaryContainer)
|
||||
Text(
|
||||
symbol.label,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairSwitch(current: RepositoryPath, counterpart: RepositoryPath, onSwitch: () -> Unit) {
|
||||
val currentIsHeader = cppRoleOf(current) == CppFileRole.HEADER
|
||||
val header = if (currentIsHeader) current else counterpart
|
||||
val source = if (currentIsHeader) counterpart else current
|
||||
val label = stringResource(R.string.codeviewer_pair_label)
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.semantics { contentDescription = label }
|
||||
.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
PairSegment(header, R.string.codeviewer_pair_header, currentIsHeader, Modifier.weight(1f), onSwitch)
|
||||
PairSegment(source, R.string.codeviewer_pair_source, !currentIsHeader, Modifier.weight(1f), onSwitch)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairSegment(path: RepositoryPath, @StringRes role: Int, isCurrent: Boolean, modifier: Modifier, onSwitch: () -> Unit) {
|
||||
Surface(
|
||||
color = if (isCurrent) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = if (isCurrent) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
shadowElevation = if (isCurrent) 1.dp else 0.dp,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable(enabled = !isCurrent, role = Role.Tab, onClick = onSwitch)
|
||||
.semantics { selected = isCurrent },
|
||||
) {
|
||||
Row(
|
||||
Modifier.heightIn(min = 44.dp).padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
Text(
|
||||
path.value.substringAfterLast('/'),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Text(stringResource(role), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JumpToCounterpart(counterpart: RepositoryPath, modifier: Modifier = Modifier, onJump: () -> Unit) {
|
||||
val toHeader = cppRoleOf(counterpart) == CppFileRole.HEADER
|
||||
Surface(
|
||||
onClick = onJump,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
shape = CircleShape,
|
||||
shadowElevation = 6.dp,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
Modifier.heightIn(min = 48.dp).padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
UBIcon(R.drawable.ic_ub_split, null, size = 20.dp)
|
||||
Text(
|
||||
stringResource(
|
||||
if (toHeader) R.string.codeviewer_jump_to_header else R.string.codeviewer_jump_to_source,
|
||||
counterpart.value.substringAfterLast('/'),
|
||||
),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
UBIcon(R.drawable.ic_ub_chevron_right, null, size = 18.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A header and its source, side by side, header on the left whichever of the two was opened. */
|
||||
@Composable
|
||||
private fun PairPanes(
|
||||
state: CodeBrowserUiState.File,
|
||||
document: CodeDocument,
|
||||
listState: LazyListState,
|
||||
preferences: CodeViewerPreferences,
|
||||
highlight: SearchHighlight,
|
||||
focusedLine: Int?,
|
||||
syncPanes: Boolean,
|
||||
counterpartPath: RepositoryPath,
|
||||
) {
|
||||
val otherText = ((state.counterpart as? CounterpartUiState.Loaded)?.content as? ContentClassification.Text)?.renderedText
|
||||
val otherDocument = if (otherText != null) rememberCodeDocument(counterpartPath, otherText) else null
|
||||
val otherState = remember(counterpartPath) { LazyListState() }
|
||||
if (syncPanes && otherDocument != null) {
|
||||
FollowSymbols(listState, document, otherState, otherDocument)
|
||||
FollowSymbols(otherState, otherDocument, listState, document)
|
||||
}
|
||||
val currentIsHeader = cppRoleOf(state.path) == CppFileRole.HEADER
|
||||
val current: @Composable (Modifier) -> Unit = { modifier ->
|
||||
Column(modifier) {
|
||||
PaneHeader(document.name, state.path, document.lines.size, enclosingSymbols(document.symbols, listState.firstVisibleItemIndex))
|
||||
CodePane(document, listState, preferences.wordWrap, preferences.fontSizeSp, search = highlight, focusedLine = focusedLine)
|
||||
}
|
||||
}
|
||||
val other: @Composable (Modifier) -> Unit = { modifier ->
|
||||
Column(modifier) {
|
||||
val name = counterpartPath.value.substringAfterLast('/')
|
||||
when {
|
||||
otherDocument != null -> {
|
||||
PaneHeader(name, counterpartPath, otherDocument.lines.size, enclosingSymbols(otherDocument.symbols, otherState.firstVisibleItemIndex))
|
||||
CodePane(otherDocument, otherState, preferences.wordWrap, preferences.fontSizeSp)
|
||||
}
|
||||
else -> {
|
||||
PaneHeader(name, counterpartPath, null, emptyList())
|
||||
when (state.counterpart) {
|
||||
CounterpartUiState.Failed -> CenteredMessage(stringResource(R.string.codeviewer_counterpart_failed))
|
||||
is CounterpartUiState.Loaded -> CenteredMessage(stringResource(R.string.codeviewer_counterpart_not_text))
|
||||
else -> CenteredLoading(stringResource(R.string.codeviewer_counterpart_loading))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
(if (currentIsHeader) current else other)(Modifier.weight(1f).fillMaxHeight())
|
||||
VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
(if (currentIsHeader) other else current)(Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* While the reader drags [leader], keeps [follower] on the declaration that matches the method at
|
||||
* the top of [leader]. Only a drag leads: a scroll this effect started itself must not lead the
|
||||
* other pane back.
|
||||
*/
|
||||
@Composable
|
||||
private fun FollowSymbols(leader: LazyListState, leaderDocument: CodeDocument, follower: LazyListState, followerDocument: CodeDocument) {
|
||||
val dragged by leader.interactionSource.collectIsDraggedAsState()
|
||||
LaunchedEffect(leader, follower, leaderDocument, followerDocument, dragged) {
|
||||
if (!dragged) return@LaunchedEffect
|
||||
snapshotFlow {
|
||||
enclosingSymbols(leaderDocument.symbols, leader.firstVisibleItemIndex).lastOrNull { it.kind == SymbolKind.METHOD }?.name
|
||||
}
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.collect { name -> findSymbolNamed(followerDocument.symbols, name)?.let { follower.animateScrollToItem(it.line) } }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaneHeader(name: String, path: RepositoryPath, lineCount: Int?, location: List<CodeSymbol>) {
|
||||
val role = when (cppRoleOf(path)) {
|
||||
CppFileRole.HEADER -> R.string.codeviewer_pair_header
|
||||
CppFileRole.SOURCE -> R.string.codeviewer_pair_source
|
||||
null -> null
|
||||
}
|
||||
Column {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
name,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false).semantics { heading() },
|
||||
)
|
||||
if (role != null) StatusBadge(stringResource(role), ActivityTone.BRAND)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (lineCount != null) {
|
||||
Text(
|
||||
stringResource(R.string.codeviewer_line_count, lineCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
LocationStrip(location)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
|
||||
/** Beside the code on a wide window: the directory's files, and the open file's outline. */
|
||||
@Composable
|
||||
private fun SidePane(
|
||||
state: CodeBrowserUiState.File,
|
||||
document: CodeDocument,
|
||||
currentLine: Int,
|
||||
onOpenFile: (RepositoryPath) -> Unit,
|
||||
onOpenEntry: (TreeEntry) -> Unit,
|
||||
onSymbolSelected: (CodeSymbol) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val current = enclosingSymbols(document.symbols, currentLine).lastOrNull()
|
||||
LazyColumn(modifier.background(MaterialTheme.colorScheme.surface), contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||
if (state.siblingEntries.isNotEmpty()) {
|
||||
item(key = "files-title") { SidePaneTitle(stringResource(R.string.codeviewer_files_title)) }
|
||||
items(state.siblingEntries, key = { "file:" + it.path.value }) { entry ->
|
||||
val isFile = entry.type == TreeEntryType.FILE
|
||||
val isCurrent = entry.path == state.path
|
||||
SidePaneRow(
|
||||
icon = if (entry.type == TreeEntryType.DIRECTORY) R.drawable.ic_ub_folder else R.drawable.ic_ub_file,
|
||||
text = entry.name,
|
||||
isCurrent = isCurrent,
|
||||
accent = entry.type == TreeEntryType.DIRECTORY,
|
||||
onClick = { if (isFile) onOpenFile(entry.path) else onOpenEntry(entry) },
|
||||
)
|
||||
}
|
||||
item(key = "divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp), color = MaterialTheme.colorScheme.outlineVariant) }
|
||||
}
|
||||
if (document.symbols.isNotEmpty()) {
|
||||
item(key = "outline-title") { SidePaneTitle(stringResource(R.string.codeviewer_outline_title)) }
|
||||
items(document.symbols, key = { "symbol:" + it.line + ":" + it.name }) { symbol ->
|
||||
SymbolRow(symbol, isCurrent = symbol == current, compact = true) { onSymbolSelected(symbol) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidePaneTitle(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp).semantics { heading() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidePaneRow(@DrawableRes icon: Int, text: String, isCurrent: Boolean, accent: Boolean, onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(if (isCurrent) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface)
|
||||
.clickable(role = Role.Button, onClick = onClick)
|
||||
.semantics { selected = isCurrent }
|
||||
.heightIn(min = 44.dp)
|
||||
.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
UBIcon(icon, null, size = 18.dp, tint = if (accent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SymbolRow(symbol: CodeSymbol, isCurrent: Boolean, compact: Boolean, onClick: () -> Unit) {
|
||||
val colors = rememberCodeSyntaxColors()
|
||||
val (letter, color, kind) = when (symbol.kind) {
|
||||
SymbolKind.NAMESPACE -> Triple("N", MaterialTheme.colorScheme.onSurfaceVariant, R.string.codeviewer_symbol_namespace)
|
||||
SymbolKind.TYPE -> Triple("C", colors.type, R.string.codeviewer_symbol_type)
|
||||
SymbolKind.METHOD -> Triple("M", colors.controlKeyword, R.string.codeviewer_symbol_method)
|
||||
SymbolKind.FIELD -> Triple("F", colors.keyword, R.string.codeviewer_symbol_field)
|
||||
}
|
||||
val kindLabel = stringResource(kind)
|
||||
val step: Dp = if (compact) 12.dp else 16.dp
|
||||
val base: Dp = if (compact) 14.dp else 20.dp
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(if (isCurrent) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface)
|
||||
.clickable(role = Role.Button, onClick = onClick)
|
||||
.semantics { selected = isCurrent }
|
||||
.heightIn(min = if (compact) 44.dp else 48.dp)
|
||||
.padding(start = base + step * symbol.depth, end = base),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
contentColor = color,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
border = BorderStroke(1.5.dp, color),
|
||||
modifier = Modifier.size(22.dp).semantics { contentDescription = kindLabel },
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(letter, fontFamily = FontFamily.Monospace, fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
symbol.label,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = if (compact) 13.sp else 14.sp,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!compact) {
|
||||
Text(
|
||||
stringResource(R.string.codeviewer_outline_line, symbol.line + 1),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun OutlineSheet(
|
||||
document: CodeDocument,
|
||||
currentLine: Int,
|
||||
onSymbolSelected: (CodeSymbol) -> Unit,
|
||||
onLineEntered: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var lineInput by remember { mutableStateOf("") }
|
||||
var outOfRange by remember { mutableStateOf(false) }
|
||||
val current = enclosingSymbols(document.symbols, currentLine).lastOrNull()
|
||||
fun submit() {
|
||||
val line = parseLineNumber(lineInput, document.lines.size)
|
||||
if (line == null) outOfRange = true else onLineEntered(line)
|
||||
}
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.padding(start = 20.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(R.string.codeviewer_outline_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f).semantics { heading() },
|
||||
)
|
||||
IconButton(onClick = onDismiss) { UBIcon(R.drawable.ic_ub_close, stringResource(R.string.codeviewer_outline_close)) }
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = lineInput,
|
||||
onValueChange = {
|
||||
lineInput = it.filter(Char::isDigit).take(MAX_LINE_DIGITS)
|
||||
outOfRange = false
|
||||
},
|
||||
singleLine = true,
|
||||
label = { Text(stringResource(R.string.codeviewer_outline_hint)) },
|
||||
isError = outOfRange,
|
||||
supportingText = if (outOfRange) {
|
||||
{ Text(stringResource(R.string.codeviewer_outline_line_out_of_range, document.lines.size)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = { submit() }),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = ::submit, enabled = lineInput.isNotEmpty()) {
|
||||
Text(stringResource(R.string.codeviewer_outline_go), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
if (document.symbols.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.codeviewer_outline_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxWidth(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
items(document.symbols, key = { "${it.line}:${it.name}" }) { symbol ->
|
||||
SymbolRow(symbol, isCurrent = symbol == current, compact = false) { onSymbolSelected(symbol) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val SEARCH_CONTEXT_LINES = 3
|
||||
private const val MAX_LINE_DIGITS = 6
|
||||
@ -0,0 +1,60 @@
|
||||
package com.ubisam.ubgitea.feature.codebrowser.content
|
||||
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
|
||||
/** One occurrence of a search text: `[start, end)` on zero-based [line]. */
|
||||
data class SearchMatch(val line: Int, val start: Int, val end: Int)
|
||||
|
||||
/** Every occurrence of [query] in [lines], in reading order. A blank query matches nothing. */
|
||||
fun findMatches(lines: List<String>, query: String, ignoreCase: Boolean = true): List<SearchMatch> {
|
||||
if (query.isEmpty()) return emptyList()
|
||||
val matches = mutableListOf<SearchMatch>()
|
||||
lines.forEachIndexed { index, line ->
|
||||
var from = 0
|
||||
while (true) {
|
||||
val found = line.indexOf(query, from, ignoreCase)
|
||||
if (found < 0) break
|
||||
matches += SearchMatch(index, found, found + query.length)
|
||||
from = found + query.length
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
/** The index in [matches] of the first match at or after [line], wrapping to the first match. */
|
||||
fun firstMatchFrom(matches: List<SearchMatch>, line: Int): Int =
|
||||
matches.indexOfFirst { it.line >= line }.let { if (it < 0) 0 else it }
|
||||
|
||||
/** What a "go to line" entry means: a one-based line number inside the file, or null when it is not one. */
|
||||
fun parseLineNumber(input: String, lineCount: Int): Int? =
|
||||
input.trim().removePrefix(":").toIntOrNull()?.takeIf { it in 1..lineCount }
|
||||
|
||||
private val HEADER_EXTENSIONS = listOf("h", "hpp", "hh", "hxx")
|
||||
private val SOURCE_EXTENSIONS = listOf("cpp", "cc", "cxx", "c")
|
||||
|
||||
enum class CppFileRole { HEADER, SOURCE }
|
||||
|
||||
fun cppRoleOf(path: RepositoryPath): CppFileRole? = when (path.extension()) {
|
||||
in HEADER_EXTENSIONS -> CppFileRole.HEADER
|
||||
in SOURCE_EXTENSIONS -> CppFileRole.SOURCE
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* The file that pairs with [path]: the source of a header, or the header of a source. Only a file
|
||||
* in the same directory with the same name counts. A project that keeps headers under `include/`
|
||||
* and sources under `src/` has its pairs in different directories, which this does not search.
|
||||
*/
|
||||
fun counterpartOf(path: RepositoryPath, siblings: List<RepositoryPath>): RepositoryPath? {
|
||||
val wanted = when (cppRoleOf(path)) {
|
||||
CppFileRole.HEADER -> SOURCE_EXTENSIONS
|
||||
CppFileRole.SOURCE -> HEADER_EXTENSIONS
|
||||
null -> return null
|
||||
}
|
||||
val stem = path.value.substringBeforeLast('.')
|
||||
return wanted.firstNotNullOfOrNull { extension ->
|
||||
siblings.firstOrNull { it != path && it.extension() == extension && it.value.substringBeforeLast('.') == stem }
|
||||
}
|
||||
}
|
||||
|
||||
private fun RepositoryPath.extension(): String = value.substringAfterLast('/').substringAfterLast('.', "").lowercase()
|
||||
@ -0,0 +1,284 @@
|
||||
package com.ubisam.ubgitea.feature.codebrowser.content
|
||||
|
||||
enum class SymbolKind { NAMESPACE, TYPE, METHOD, FIELD }
|
||||
|
||||
/**
|
||||
* One entry of a file's outline.
|
||||
*
|
||||
* @property name the bare name, which is what a header and its source file share
|
||||
* @property label the name as shown: a method carries its parameter types, e.g. `NextDelay(int)`
|
||||
* @property line zero-based line the declaration starts on
|
||||
* @property endLine zero-based last line of the declaration's body; equal to [line] when it has none
|
||||
* @property depth how many namespaces and types enclose it
|
||||
*/
|
||||
data class CodeSymbol(
|
||||
val name: String,
|
||||
val label: String,
|
||||
val kind: SymbolKind,
|
||||
val line: Int,
|
||||
val endLine: Int,
|
||||
val depth: Int,
|
||||
)
|
||||
|
||||
/** Whether [extractSymbols] understands [language] at all; the outline is not offered otherwise. */
|
||||
fun supportsOutline(language: SyntaxLanguage): Boolean = language in OUTLINE_LANGUAGES
|
||||
|
||||
private val OUTLINE_LANGUAGES = setOf(SyntaxLanguage.CSHARP, SyntaxLanguage.CPP, SyntaxLanguage.JAVA, SyntaxLanguage.KOTLIN)
|
||||
|
||||
/**
|
||||
* Finds namespaces, types, methods and fields by pattern. This is not a parser: it follows braces
|
||||
* to know what encloses what, and recognizes declarations by their shape on the line they start
|
||||
* on. It is right for code written the usual way and can miss or misname a declaration written
|
||||
* unusually (split across lines before its name, or produced by a macro). Comments and string
|
||||
* literals are blanked out first, so braces and look-alike text inside them are not counted.
|
||||
*/
|
||||
fun extractSymbols(lines: List<String>, language: SyntaxLanguage): List<CodeSymbol> {
|
||||
if (!supportsOutline(language)) return emptyList()
|
||||
val code = blankCommentsAndStrings(lines, language)
|
||||
val symbols = mutableListOf<Pending>()
|
||||
val scopes = ArrayDeque<Scope>()
|
||||
var awaitingBody: Pending? = null
|
||||
var linesAwaited = 0
|
||||
|
||||
code.forEachIndexed { index, line ->
|
||||
val enclosing = scopes.lastOrNull()?.kind
|
||||
val canDeclare = enclosing == null || enclosing == ScopeKind.NAMESPACE || enclosing == ScopeKind.TYPE
|
||||
if (awaitingBody != null && ++linesAwaited > MAX_LINES_AWAITING_BODY) awaitingBody = null
|
||||
var declared: Pending? = null
|
||||
if (canDeclare && awaitingBody == null) {
|
||||
declared = declarationOn(line, language, insideType = enclosing == ScopeKind.TYPE)?.let { found ->
|
||||
Pending(found, index, depth = scopes.count { it.kind == ScopeKind.NAMESPACE || it.kind == ScopeKind.TYPE })
|
||||
}
|
||||
if (declared != null) {
|
||||
symbols += declared
|
||||
if (declared.found.kind != SymbolKind.FIELD) {
|
||||
awaitingBody = declared
|
||||
linesAwaited = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
for (character in line) {
|
||||
when (character) {
|
||||
'{' -> {
|
||||
val owner = awaitingBody
|
||||
awaitingBody = null
|
||||
scopes.addLast(Scope(owner?.found?.kind.toScopeKind(), owner))
|
||||
}
|
||||
'}' -> scopes.removeLastOrNull()?.symbol?.endLine = index
|
||||
// A declaration that ends before any body opens has none: a prototype, an abstract
|
||||
// method -- or a forward declaration such as `class Foo;`, which names a type that
|
||||
// is defined elsewhere and so is not listed.
|
||||
';' -> awaitingBody?.let { bodiless ->
|
||||
if (bodiless.found.kind == SymbolKind.TYPE) symbols -= bodiless
|
||||
awaitingBody = null
|
||||
}
|
||||
}
|
||||
}
|
||||
// Kotlin has no `;` to say a declaration is over: `fun a() = 1` simply ends. Only a line
|
||||
// that visibly continues keeps waiting for a body.
|
||||
if (language == SyntaxLanguage.KOTLIN && declared != null && awaitingBody === declared) {
|
||||
val end = line.trimEnd().lastOrNull()
|
||||
if (end != '(' && end != ',') awaitingBody = null
|
||||
}
|
||||
}
|
||||
return symbols.map { pending ->
|
||||
CodeSymbol(
|
||||
name = pending.found.name,
|
||||
label = pending.found.label,
|
||||
kind = pending.found.kind,
|
||||
line = pending.line,
|
||||
endLine = pending.endLine ?: pending.line,
|
||||
depth = pending.depth,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The symbols enclosing [line], outermost first: what a "you are here" strip shows. */
|
||||
fun enclosingSymbols(symbols: List<CodeSymbol>, line: Int): List<CodeSymbol> =
|
||||
symbols.filter { it.kind != SymbolKind.FIELD && line in it.line..it.endLine }.sortedBy(CodeSymbol::depth)
|
||||
|
||||
/** The symbol in [symbols] that a symbol named [name] in a counterpart file corresponds to. */
|
||||
fun findSymbolNamed(symbols: List<CodeSymbol>, name: String, kind: SymbolKind? = null): CodeSymbol? =
|
||||
symbols.firstOrNull { it.name == name && (kind == null || it.kind == kind) }
|
||||
?: symbols.firstOrNull { it.name == name }
|
||||
|
||||
// BLOCK is a brace that belongs to no declaration: a statement block, an initializer, an accessor list.
|
||||
private enum class ScopeKind { NAMESPACE, TYPE, METHOD, BLOCK }
|
||||
|
||||
private fun SymbolKind?.toScopeKind(): ScopeKind = when (this) {
|
||||
SymbolKind.NAMESPACE -> ScopeKind.NAMESPACE
|
||||
SymbolKind.TYPE -> ScopeKind.TYPE
|
||||
SymbolKind.METHOD -> ScopeKind.METHOD
|
||||
SymbolKind.FIELD, null -> ScopeKind.BLOCK
|
||||
}
|
||||
|
||||
// A declaration whose body has not opened after this many lines was not one (a macro, most likely).
|
||||
private const val MAX_LINES_AWAITING_BODY = 12
|
||||
|
||||
private class Scope(val kind: ScopeKind, val symbol: Pending?)
|
||||
|
||||
private class Pending(val found: Found, val line: Int, val depth: Int) {
|
||||
var endLine: Int? = null
|
||||
}
|
||||
|
||||
private class Found(val name: String, val label: String, val kind: SymbolKind)
|
||||
|
||||
private fun blankCommentsAndStrings(lines: List<String>, language: SyntaxLanguage): List<String> =
|
||||
tokenizeLines(lines, language).mapIndexed { index, tokens ->
|
||||
val characters = lines[index].toCharArray()
|
||||
tokens.filter { it.role == TokenRole.COMMENT || it.role == TokenRole.STRING || it.role == TokenRole.PREPROCESSOR }
|
||||
.forEach { token -> for (i in token.start until token.end) characters[i] = ' ' }
|
||||
// A preprocessor line is no declaration, and a macro body's braces are not scopes.
|
||||
if (tokens.firstOrNull()?.role == TokenRole.PREPROCESSOR) "" else String(characters)
|
||||
}
|
||||
|
||||
private const val NAME = "[A-Za-z_][A-Za-z0-9_]*"
|
||||
|
||||
private val STATEMENT_WORDS = setOf(
|
||||
"if", "else", "for", "foreach", "while", "do", "switch", "case", "return", "throw", "catch", "try",
|
||||
"finally", "using", "lock", "new", "delete", "await", "yield", "goto", "sizeof", "typedef", "friend",
|
||||
"when", "fixed", "checked", "unchecked",
|
||||
)
|
||||
|
||||
private const val CS_MODIFIER =
|
||||
"(?:public|private|protected|internal|static|virtual|override|abstract|async|sealed|extern|unsafe|new|partial|readonly|const|volatile|required|final|synchronized|native|transient|default)"
|
||||
private const val TYPE_NAME = "[\\w.:]+(?:<[^(){};]*>)?(?:\\[[,\\s]*\\])*\\??"
|
||||
|
||||
private val NAMESPACE = Regex("^\\s*(?:inline\\s+)?namespace\\s+([\\w.:]+)")
|
||||
private val CS_TYPE = Regex("^\\s*(?:$CS_MODIFIER\\s+)*(?:class|struct|interface|enum|record(?:\\s+(?:class|struct))?)\\s+($NAME)")
|
||||
private val CS_METHOD = Regex("^\\s*((?:$CS_MODIFIER\\s+)*)(?:$TYPE_NAME\\s+)?($NAME)\\s*(?:<[^(){};]*>)?\\s*\\((.*)$")
|
||||
private val CS_MEMBER = Regex("^\\s*(?:$CS_MODIFIER\\s+)+$TYPE_NAME\\s+($NAME)\\s*(?:=.*|;|\\{.*|=>.*)?$")
|
||||
private val CPP_TYPE = Regex("^\\s*(?:template\\s*<[^{};]*>\\s*)?(?:class|struct|union|enum(?:\\s+(?:class|struct))?)\\s+(?:[A-Z_]+\\s+)?($NAME)")
|
||||
private val CPP_FUNCTION = Regex("^\\s*(?:[\\w:<>,*&~\\s\\[\\]]*?[\\s*&])?((?:$NAME(?:<[^(){};]*>)?::)*~?$NAME)\\s*\\((.*)$")
|
||||
private val CPP_MEMBER = Regex("^\\s*[\\w:<>,\\s]+?[\\s*&]+($NAME)\\s*(?:\\[[^\\]]*\\])?\\s*(?:=[^;]*|\\{[^}]*\\})?;\\s*$")
|
||||
private val KOTLIN_TYPE = Regex("^\\s*(?:[a-z]+\\s+)*(?:class|interface|object)\\s+($NAME)")
|
||||
private val KOTLIN_FUNCTION = Regex("^\\s*(?:[a-z]+\\s+)*fun\\s+(?:<[^(){};]*>\\s*)?(?:[\\w.<>?, ]+\\.)?($NAME)\\s*\\((.*)$")
|
||||
private val KOTLIN_PROPERTY = Regex("^\\s*(?:[a-z]+\\s+)*(?:val|var)\\s+($NAME)")
|
||||
|
||||
private fun declarationOn(line: String, language: SyntaxLanguage, insideType: Boolean): Found? {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.isEmpty() || trimmed.first() in ":,.)]}=+-*/&|?@") return null
|
||||
val firstWord = trimmed.takeWhile { it.isLetterOrDigit() || it == '_' }
|
||||
return when (language) {
|
||||
SyntaxLanguage.CPP -> cppDeclaration(line, trimmed, firstWord, insideType)
|
||||
SyntaxLanguage.KOTLIN -> kotlinDeclaration(line, insideType)
|
||||
else -> csharpDeclaration(line, trimmed, firstWord, insideType)
|
||||
}
|
||||
}
|
||||
|
||||
private fun csharpDeclaration(line: String, trimmed: String, firstWord: String, insideType: Boolean): Found? {
|
||||
NAMESPACE.find(line)?.let { return Found(it.groupValues[1], it.groupValues[1], SymbolKind.NAMESPACE) }
|
||||
CS_TYPE.find(line)?.let { return Found(it.groupValues[1], it.groupValues[1], SymbolKind.TYPE) }
|
||||
if (!insideType || firstWord in STATEMENT_WORDS || trimmed.startsWith("[")) return null
|
||||
CS_METHOD.matchEntire(line)?.let { match ->
|
||||
val hasModifier = match.groupValues[1].isNotBlank()
|
||||
val name = match.groupValues[2]
|
||||
val beforeParenthesis = line.substringBefore('(')
|
||||
val isCall = '=' in beforeParenthesis || '.' in beforeParenthesis.trim().substringAfterLast(' ')
|
||||
// Without a modifier, `Foo(bar);` is a statement and `void Foo()` is a declaration: the
|
||||
// difference is whether a second word (the return type) precedes the name.
|
||||
val hasReturnType = beforeParenthesis.trim().contains(' ')
|
||||
if (!isCall && name !in STATEMENT_WORDS && (hasModifier || hasReturnType)) {
|
||||
return Found(name, name + parameterTypes(match.groupValues[3]), SymbolKind.METHOD)
|
||||
}
|
||||
}
|
||||
CS_MEMBER.matchEntire(line)?.let { return Found(it.groupValues[1], it.groupValues[1], SymbolKind.FIELD) }
|
||||
return null
|
||||
}
|
||||
|
||||
private fun cppDeclaration(line: String, trimmed: String, firstWord: String, insideType: Boolean): Found? {
|
||||
NAMESPACE.find(line)?.let { return Found(it.groupValues[1], it.groupValues[1], SymbolKind.NAMESPACE) }
|
||||
if (firstWord in STATEMENT_WORDS || trimmed.endsWith(":") && !trimmed.contains("(")) return null
|
||||
CPP_TYPE.find(line)?.let { match ->
|
||||
// `enum class Color : int {` and `class Foo final : public Bar {` both name the type first.
|
||||
if ('(' !in line.substringBefore(match.groupValues[1])) {
|
||||
return Found(match.groupValues[1], match.groupValues[1], SymbolKind.TYPE)
|
||||
}
|
||||
}
|
||||
if ('(' in line) {
|
||||
val beforeParenthesis = line.substringBefore('(')
|
||||
if ('=' in beforeParenthesis && "operator" !in beforeParenthesis) return null
|
||||
CPP_FUNCTION.matchEntire(line)?.let { match ->
|
||||
val qualified = match.groupValues[1]
|
||||
val name = qualified.substringAfterLast("::")
|
||||
val hasReturnType = beforeParenthesis.trim().contains(' ') || beforeParenthesis.trim().contains('*')
|
||||
// Inside a function body this would be a call; at namespace or type level a bare
|
||||
// `Name(...)` is a constructor or a macro-declared function. A free-standing call
|
||||
// (`DoThing(x);` at namespace level) is not valid C++, so it cannot be one.
|
||||
val isConstructorLike = insideType || "::" in qualified
|
||||
if (name !in STATEMENT_WORDS && (hasReturnType || isConstructorLike)) {
|
||||
return Found(name, name + parameterTypes(match.groupValues[2]), SymbolKind.METHOD)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (insideType && firstWord !in setOf("public", "private", "protected", "using", "template")) {
|
||||
CPP_MEMBER.matchEntire(line)?.let { return Found(it.groupValues[1], it.groupValues[1], SymbolKind.FIELD) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun kotlinDeclaration(line: String, insideType: Boolean): Found? {
|
||||
KOTLIN_TYPE.find(line)?.let { return Found(it.groupValues[1], it.groupValues[1], SymbolKind.TYPE) }
|
||||
KOTLIN_FUNCTION.matchEntire(line)?.let { match ->
|
||||
return Found(match.groupValues[1], match.groupValues[1] + kotlinParameterTypes(match.groupValues[2]), SymbolKind.METHOD)
|
||||
}
|
||||
KOTLIN_PROPERTY.find(line)?.let { return Found(it.groupValues[1], it.groupValues[1], SymbolKind.FIELD) }
|
||||
return null
|
||||
}
|
||||
|
||||
/** `(int attempt, Exception cause) const {` becomes `(int, Exception)`. `[rest]` starts right after the opening parenthesis. */
|
||||
private fun parameterTypes(rest: String): String {
|
||||
val inside = closedParameterList(rest) ?: return "(…)"
|
||||
if (inside.isBlank() || inside.trim() == "void") return "()"
|
||||
return splitTopLevel(inside).joinToString(", ", "(", ")") { parameter ->
|
||||
val words = parameter.substringBefore('=').trim().replace(PARAMETER_MODIFIER, "").split(WHITESPACE)
|
||||
// The last word is the parameter's name, unless there is only one word (an unnamed parameter).
|
||||
if (words.size == 1) {
|
||||
words[0]
|
||||
} else {
|
||||
words.dropLast(1).joinToString(" ") + words.last().takeWhile { it == '*' || it == '&' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val PARAMETER_MODIFIER = Regex("\\b(?:const|ref|out|in|params|this)\\s+")
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
|
||||
private fun kotlinParameterTypes(rest: String): String {
|
||||
val inside = closedParameterList(rest) ?: return "(…)"
|
||||
if (inside.isBlank()) return "()"
|
||||
return splitTopLevel(inside).joinToString(", ", "(", ")") { parameter ->
|
||||
parameter.substringAfter(':', parameter).substringBefore('=').trim()
|
||||
}
|
||||
}
|
||||
|
||||
/** The text between the opening parenthesis and its matching close, or null when it closes on a later line. */
|
||||
private fun closedParameterList(rest: String): String? {
|
||||
var depth = 1
|
||||
rest.forEachIndexed { index, character ->
|
||||
when (character) {
|
||||
'(' -> depth++
|
||||
')' -> if (--depth == 0) return rest.substring(0, index)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun splitTopLevel(text: String): List<String> {
|
||||
val parts = mutableListOf<String>()
|
||||
var depth = 0
|
||||
var start = 0
|
||||
text.forEachIndexed { index, character ->
|
||||
when (character) {
|
||||
'<', '(', '[' -> depth++
|
||||
'>', ')', ']' -> depth--
|
||||
',' -> if (depth == 0) {
|
||||
parts += text.substring(start, index)
|
||||
start = index + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
parts += text.substring(start)
|
||||
return parts.map(String::trim).filter(String::isNotEmpty)
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
package com.ubisam.ubgitea.feature.codebrowser.content
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
|
||||
@ -48,36 +48,48 @@ fun syntaxLanguageForPath(path: RepositoryPath): SyntaxLanguage {
|
||||
data class CodeSyntaxColors(
|
||||
val text: Color,
|
||||
val keyword: Color,
|
||||
val controlKeyword: Color,
|
||||
val type: Color,
|
||||
val method: Color,
|
||||
val string: Color,
|
||||
val comment: Color,
|
||||
val number: Color,
|
||||
val preprocessor: Color,
|
||||
)
|
||||
|
||||
// Colors are fixed (not derived from the app's own sky-blue Material theme) and only switch
|
||||
// between two hand-picked sets for light/dark -- source code highlighting has its own well-known
|
||||
// conventions (keyword blue, string orange/red, comment green) independent of a given app's brand
|
||||
// color, and reusing brand colors here would read as less familiar, not more polished.
|
||||
// Visual Studio's default colors (TASK-053; the team reads C# and C++ in Visual Studio, so the same
|
||||
// code should look the same here). Token colors are fixed rather than derived from the app's brand
|
||||
// palette -- source highlighting has its own conventions -- but the plain-text color and the
|
||||
// background are the app theme's own, so the code pane is not a differently-themed island. Two
|
||||
// light values are darker than Visual Studio's originals (type #2B91AF, comment #008000), which
|
||||
// fall below 4.5:1 on white at code size.
|
||||
@Composable
|
||||
fun rememberCodeSyntaxColors(): CodeSyntaxColors {
|
||||
val dark = isSystemInDarkTheme()
|
||||
return remember(dark) {
|
||||
val text = MaterialTheme.colorScheme.onSurface
|
||||
return remember(dark, text) {
|
||||
if (dark) {
|
||||
CodeSyntaxColors(
|
||||
text = Color(0xFFD4D4D4),
|
||||
text = text,
|
||||
keyword = Color(0xFF569CD6),
|
||||
string = Color(0xFFCE9178),
|
||||
comment = Color(0xFF6A9955),
|
||||
controlKeyword = Color(0xFFD8A0DF),
|
||||
type = Color(0xFF4EC9B0),
|
||||
method = Color(0xFFDCDCAA),
|
||||
string = Color(0xFFD69D85),
|
||||
comment = Color(0xFF57A64A),
|
||||
number = Color(0xFFB5CEA8),
|
||||
preprocessor = Color(0xFF9B9B9B),
|
||||
)
|
||||
} else {
|
||||
CodeSyntaxColors(
|
||||
text = Color(0xFF1F1F1F),
|
||||
text = text,
|
||||
keyword = Color(0xFF0000FF),
|
||||
controlKeyword = Color(0xFF8F08C4),
|
||||
type = Color(0xFF1F7A99),
|
||||
method = Color(0xFF74531F),
|
||||
string = Color(0xFFA31515),
|
||||
comment = Color(0xFF008000),
|
||||
number = Color(0xFF098658),
|
||||
comment = Color(0xFF007A00),
|
||||
number = text,
|
||||
preprocessor = Color(0xFF6B6B6B),
|
||||
)
|
||||
}
|
||||
@ -91,8 +103,21 @@ private data class LangSpec(
|
||||
val blockComment: Boolean = false,
|
||||
val xmlComment: Boolean = false,
|
||||
val preprocessor: Boolean = false,
|
||||
/** Whether identifiers that are not keywords are told apart as types and methods. */
|
||||
val identifiers: Boolean = false,
|
||||
)
|
||||
|
||||
// Flow-control words Visual Studio colors differently from other keywords. Applied only to words
|
||||
// that are keywords of the language at hand, so listing a word another language lacks is harmless.
|
||||
private val CONTROL_KEYWORDS = setOf(
|
||||
"if", "else", "switch", "case", "default", "for", "foreach", "while", "do", "break", "continue",
|
||||
"return", "throw", "try", "catch", "finally", "goto", "yield", "when", "co_return", "co_yield",
|
||||
"co_await",
|
||||
)
|
||||
|
||||
// A line that only names a namespace or package: its identifiers are neither types nor methods.
|
||||
private val NAMESPACE_LINE = Regex("^\\s*(?:using|import|package|namespace)\\s+(?:static\\s+)?[\\w.:*]+(?:\\s*=\\s*[\\w.:<>, ]+)?\\s*;?\\s*\\{?\\s*$")
|
||||
|
||||
private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
|
||||
SyntaxLanguage.KOTLIN to LangSpec(
|
||||
keywords = setOf(
|
||||
@ -106,6 +131,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
|
||||
),
|
||||
lineComment = "//",
|
||||
blockComment = true,
|
||||
identifiers = true,
|
||||
),
|
||||
SyntaxLanguage.JAVA to LangSpec(
|
||||
keywords = setOf(
|
||||
@ -118,6 +144,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
|
||||
),
|
||||
lineComment = "//",
|
||||
blockComment = true,
|
||||
identifiers = true,
|
||||
),
|
||||
SyntaxLanguage.CSHARP to LangSpec(
|
||||
keywords = setOf(
|
||||
@ -132,6 +159,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
|
||||
),
|
||||
lineComment = "//",
|
||||
blockComment = true,
|
||||
identifiers = true,
|
||||
preprocessor = true,
|
||||
),
|
||||
SyntaxLanguage.CPP to LangSpec(
|
||||
@ -150,6 +178,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
|
||||
),
|
||||
lineComment = "//",
|
||||
blockComment = true,
|
||||
identifiers = true,
|
||||
preprocessor = true,
|
||||
),
|
||||
SyntaxLanguage.JAVASCRIPT to LangSpec(
|
||||
@ -163,6 +192,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
|
||||
),
|
||||
lineComment = "//",
|
||||
blockComment = true,
|
||||
identifiers = true,
|
||||
),
|
||||
SyntaxLanguage.PYTHON to LangSpec(
|
||||
keywords = setOf(
|
||||
@ -262,9 +292,14 @@ internal fun tokenizeLines(lines: List<String>, language: SyntaxLanguage): List<
|
||||
}
|
||||
}
|
||||
}
|
||||
val plainIdentifiers = !spec.identifiers || NAMESPACE_LINE.matches(line)
|
||||
var previousWord: String? = null
|
||||
for (match in pattern.findAll(line, from)) {
|
||||
val text = match.value
|
||||
val role = classify(text, spec) ?: continue
|
||||
val role = classify(text, spec)
|
||||
?: if (plainIdentifiers) null else identifierRole(line, match.range, previousWord)
|
||||
if (text.first().isLetter() || text.first() == '_') previousWord = text
|
||||
if (role == null) continue
|
||||
tokens += SyntaxToken(match.range.first, match.range.last + 1, role)
|
||||
if (role == TokenRole.COMMENT) open = unterminatedBlockComment(text, spec)
|
||||
}
|
||||
@ -272,6 +307,27 @@ internal fun tokenizeLines(lines: List<String>, language: SyntaxLanguage): List<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells a type or a method from a plain identifier by how it is written and what surrounds it --
|
||||
* there is no symbol table here, so this is convention, not knowledge. A name followed by `(` is a
|
||||
* method, unless `new` precedes it (a constructor call names a type). A capitalized name is a type,
|
||||
* unless it is reached through `.` (a property or member) or is written all in capitals (a
|
||||
* constant or macro). A name with the `m_` or `_` field prefix is always plain.
|
||||
*/
|
||||
private fun identifierRole(line: String, range: IntRange, previousWord: String?): TokenRole? {
|
||||
val text = line.substring(range.first, range.last + 1)
|
||||
if (text.startsWith("m_") || text.startsWith("_")) return null
|
||||
val next = line.drop(range.last + 1).firstOrNull { !it.isWhitespace() }
|
||||
val before = line.take(range.first).lastOrNull { !it.isWhitespace() }
|
||||
if (next == '(') return if (previousWord == "new") TokenRole.TYPE else TokenRole.METHOD
|
||||
val capitalized = text.first().isUpperCase() && text.any(Char::isLowerCase)
|
||||
return when {
|
||||
capitalized && before != '.' -> TokenRole.TYPE
|
||||
text.endsWith("_t") && text.length > 2 -> TokenRole.TYPE
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun unterminatedBlockComment(text: String, spec: LangSpec): BlockComment? {
|
||||
val block = when {
|
||||
spec.blockComment && text.startsWith(BlockComment.C_STYLE.open) -> BlockComment.C_STYLE
|
||||
@ -295,7 +351,7 @@ private fun annotate(line: String, tokens: List<SyntaxToken>, colors: CodeSyntax
|
||||
var lastIndex = 0
|
||||
for (token in tokens) {
|
||||
if (token.start > lastIndex) append(line.substring(lastIndex, token.start))
|
||||
withStyle(SpanStyle(color = token.role.colorOf(colors), fontWeight = token.role.weight)) {
|
||||
withStyle(SpanStyle(color = token.role.colorOf(colors))) {
|
||||
append(line.substring(token.start, token.end))
|
||||
}
|
||||
lastIndex = token.end
|
||||
@ -303,12 +359,15 @@ private fun annotate(line: String, tokens: List<SyntaxToken>, colors: CodeSyntax
|
||||
if (lastIndex < line.length) append(line.substring(lastIndex))
|
||||
}
|
||||
|
||||
internal enum class TokenRole(val weight: FontWeight?) {
|
||||
COMMENT(null),
|
||||
STRING(null),
|
||||
NUMBER(null),
|
||||
KEYWORD(FontWeight.SemiBold),
|
||||
PREPROCESSOR(null),
|
||||
internal enum class TokenRole {
|
||||
COMMENT,
|
||||
STRING,
|
||||
NUMBER,
|
||||
KEYWORD,
|
||||
CONTROL_KEYWORD,
|
||||
TYPE,
|
||||
METHOD,
|
||||
PREPROCESSOR,
|
||||
;
|
||||
|
||||
fun colorOf(colors: CodeSyntaxColors): Color = when (this) {
|
||||
@ -316,6 +375,9 @@ internal enum class TokenRole(val weight: FontWeight?) {
|
||||
STRING -> colors.string
|
||||
NUMBER -> colors.number
|
||||
KEYWORD -> colors.keyword
|
||||
CONTROL_KEYWORD -> colors.controlKeyword
|
||||
TYPE -> colors.type
|
||||
METHOD -> colors.method
|
||||
PREPROCESSOR -> colors.preprocessor
|
||||
}
|
||||
}
|
||||
@ -325,7 +387,7 @@ private fun classify(text: String, spec: LangSpec): TokenRole? = when {
|
||||
spec.xmlComment && text.startsWith("<!--") -> TokenRole.COMMENT
|
||||
text.startsWith("\"") || text.startsWith("'") -> TokenRole.STRING
|
||||
text.first().isDigit() -> TokenRole.NUMBER
|
||||
isKeyword(text, spec) -> TokenRole.KEYWORD
|
||||
isKeyword(text, spec) -> if (spec.identifiers && text in CONTROL_KEYWORDS) TokenRole.CONTROL_KEYWORD else TokenRole.KEYWORD
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,357 @@
|
||||
package com.ubisam.ubgitea.feature.history
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.DisableSelection
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextIndent
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.domain.history.DiffEntry
|
||||
import com.ubisam.ubgitea.domain.history.DiffLine
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.highlightLine
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
||||
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
|
||||
import com.ubisam.ubgitea.ui.components.CautionBanner
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import com.ubisam.ubgitea.ui.shell.WideLayoutBreakpoint
|
||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* One file's changes, full screen. On a narrow window the diff is unified: one column, removed and
|
||||
* added lines in reading order, long lines wrapped. On a wide window -- an unfolded Z Fold -- it is
|
||||
* split down the middle, before on the left and after on the right, each row as tall as the taller
|
||||
* of its two sides so the two stay level.
|
||||
*
|
||||
* Added and removed lines are marked with `+` and `−` as well as with color, so the difference does
|
||||
* not depend on telling green from red.
|
||||
*/
|
||||
@Composable
|
||||
fun FileDiffScreen(
|
||||
state: HistoryUiState.CommitDetailContent,
|
||||
fontSizeSp: Int,
|
||||
onShowAdjacentFile: (Int) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val selected = state.selectedFile ?: return
|
||||
BackHandler(onBack = onClose)
|
||||
val listState = remember(selected.file.path) { LazyListState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val entries = selected.entries
|
||||
val changeStarts = remember(entries) { changeStartsOf(entries) }
|
||||
val fileIndex = state.files.indexOfFirst { it.path == selected.file.path }
|
||||
|
||||
fun jump(direction: Int) {
|
||||
val current = listState.firstVisibleItemIndex
|
||||
val target = if (direction > 0) changeStarts.firstOrNull { it > current } else changeStarts.lastOrNull { it < current }
|
||||
if (target != null) scope.launch { listState.animateScrollToItem((target - CHANGE_CONTEXT_ROWS).coerceAtLeast(0)) }
|
||||
}
|
||||
|
||||
Column(modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).statusBarsPadding()) {
|
||||
ScreenTopBar(
|
||||
title = state.commit.message.lineSequence().firstOrNull().orEmpty(),
|
||||
subtitle = stringResource(R.string.history_commit_by_at, state.commit.shortId, state.commit.authorName),
|
||||
onBack = onClose,
|
||||
)
|
||||
FileBar(
|
||||
name = selected.file.path.substringAfterLast('/'),
|
||||
position = fileIndex + 1,
|
||||
total = state.files.size,
|
||||
added = entries.count { it.right is DiffLine.Added },
|
||||
removed = entries.count { it.left is DiffLine.Removed },
|
||||
onPrevious = { onShowAdjacentFile(-1) },
|
||||
onNext = { onShowAdjacentFile(1) },
|
||||
)
|
||||
if (selected.isTruncated) CautionBanner(stringResource(R.string.history_patch_truncated))
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
val sideBySide = maxWidth >= WideLayoutBreakpoint
|
||||
if (entries.none { !it.isHunkHeader }) {
|
||||
CenteredMessage(stringResource(R.string.history_file_diff_unavailable))
|
||||
} else {
|
||||
DiffList(entries, selected.file.path, fontSizeSp, sideBySide, listState)
|
||||
}
|
||||
}
|
||||
DiffToolbar(
|
||||
canJump = changeStarts.isNotEmpty(),
|
||||
onPreviousChange = { jump(-1) },
|
||||
onNextChange = { jump(1) },
|
||||
onFileList = onClose,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The index of the first row of every run of changed rows: where "next change" lands. */
|
||||
internal fun changeStartsOf(entries: List<DiffEntry>): List<Int> =
|
||||
entries.indices.filter { index -> entries[index].isChange && (index == 0 || !entries[index - 1].isChange) }
|
||||
|
||||
@Composable
|
||||
private fun FileBar(name: String, position: Int, total: Int, added: Int, removed: Int, onPrevious: () -> Unit, onNext: () -> Unit) {
|
||||
val addedDescription = stringResource(R.string.history_diff_added_description, added)
|
||||
val removedDescription = stringResource(R.string.history_diff_removed_description, removed)
|
||||
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||
Column {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onPrevious, enabled = position > 1) {
|
||||
UBIcon(R.drawable.ic_ub_back, stringResource(R.string.history_diff_previous_file))
|
||||
}
|
||||
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
name,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (position > 0) {
|
||||
Text(
|
||||
stringResource(R.string.history_diff_file_position, position, total),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.history_diff_added_count, added),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = UBGiteaTheme.colors.success,
|
||||
modifier = Modifier.semantics { contentDescription = addedDescription },
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.history_diff_removed_count, removed),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.semantics { contentDescription = removedDescription },
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onNext, enabled = position in 1 until total) {
|
||||
UBIcon(R.drawable.ic_ub_chevron_right, stringResource(R.string.history_diff_next_file))
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffList(entries: List<DiffEntry>, path: String, fontSizeSp: Int, sideBySide: Boolean, listState: LazyListState) {
|
||||
val colors = rememberCodeSyntaxColors()
|
||||
val language = remember(path) {
|
||||
try {
|
||||
syntaxLanguageForPath(RepositoryPath.of(path))
|
||||
} catch (_: IllegalArgumentException) {
|
||||
com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage.PLAIN
|
||||
}
|
||||
}
|
||||
val style = remember(fontSizeSp, colors.text) {
|
||||
TextStyle(fontFamily = FontFamily.Monospace, fontSize = fontSizeSp.sp, lineHeight = (fontSizeSp * 1.5f).sp, color = colors.text)
|
||||
}
|
||||
val measurer = rememberTextMeasurer()
|
||||
val characterWidthPx = remember(style) { measurer.measure("0", style).size.width.toFloat() }
|
||||
val density = LocalDensity.current
|
||||
val digits = entries.maxOf { maxOf(it.leftNumber ?: 0, it.rightNumber ?: 0) }.toString().length
|
||||
val numberWidth = with(density) { (characterWidthPx * digits).toDp() } + 18.dp
|
||||
val cell = DiffCellStyle(style, characterWidthPx, numberWidth)
|
||||
|
||||
SelectionContainer {
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||
items(entries.size, key = { it }) { index ->
|
||||
val entry = entries[index]
|
||||
when {
|
||||
entry.isHunkHeader -> HunkHeader(entry.hunkLabel.orEmpty())
|
||||
sideBySide -> Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
DiffCell(entry.left, entry.leftNumber, language, cell, Modifier.weight(1f).fillMaxHeight())
|
||||
Box(Modifier.width(1.dp).fillMaxHeight().background(MaterialTheme.colorScheme.outlineVariant))
|
||||
DiffCell(entry.right, entry.rightNumber, language, cell, Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
// Unified: a changed row is its removed line, then its added line.
|
||||
else -> Column(Modifier.fillMaxWidth()) {
|
||||
if (entry.left != null && entry.left !is DiffLine.Context) {
|
||||
DiffCell(entry.left, entry.leftNumber, language, cell, Modifier.fillMaxWidth())
|
||||
}
|
||||
if (entry.right != null) DiffCell(entry.right, entry.rightNumber, language, cell, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DiffCellStyle(val text: TextStyle, val characterWidthPx: Float, val numberWidth: Dp)
|
||||
|
||||
@Composable
|
||||
private fun HunkHeader(label: String) {
|
||||
val description = stringResource(R.string.history_diff_hunk)
|
||||
DisableSelection {
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(top = 6.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.semantics(mergeDescendants = true) { contentDescription = "$description $label" }
|
||||
.heightIn(min = 28.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
UBIcon(R.drawable.ic_ub_unfold, null, size = 14.dp, tint = MaterialTheme.colorScheme.onPrimaryContainer)
|
||||
Text(
|
||||
label,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffCell(
|
||||
line: DiffLine?,
|
||||
number: Int?,
|
||||
language: com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage,
|
||||
cell: DiffCellStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (line == null) {
|
||||
// The other side has a line this side lacks.
|
||||
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant))
|
||||
return
|
||||
}
|
||||
val colors = rememberCodeSyntaxColors()
|
||||
val background = when (line) {
|
||||
is DiffLine.Added -> UBGiteaTheme.colors.successContainer
|
||||
is DiffLine.Removed -> MaterialTheme.colorScheme.errorContainer
|
||||
is DiffLine.Context -> Color.Transparent
|
||||
}
|
||||
val (sign, signColor, signLabel) = when (line) {
|
||||
is DiffLine.Added -> Triple("+", UBGiteaTheme.colors.success, stringResource(R.string.history_diff_line_added))
|
||||
is DiffLine.Removed -> Triple("−", MaterialTheme.colorScheme.error, stringResource(R.string.history_diff_line_removed))
|
||||
is DiffLine.Context -> Triple("", Color.Transparent, null)
|
||||
}
|
||||
val shown: AnnotatedString = remember(line, language, colors) { highlightLine(line.text, language, colors) }
|
||||
val indent = line.text.takeWhile { it == ' ' || it == '\t' }.sumOf { if (it == '\t') 4 else 1 }
|
||||
val hanging = with(LocalDensity.current) { (cell.characterWidthPx * (indent + 4)).toSp() }
|
||||
Row(modifier.background(background), verticalAlignment = Alignment.Top) {
|
||||
DisableSelection {
|
||||
Text(
|
||||
number?.toString().orEmpty(),
|
||||
style = cell.text,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.width(cell.numberWidth).padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
sign,
|
||||
style = cell.text,
|
||||
color = signColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.width(16.dp).semantics { if (signLabel != null) contentDescription = signLabel },
|
||||
)
|
||||
}
|
||||
Text(
|
||||
shown,
|
||||
style = cell.text.copy(textIndent = TextIndent(restLine = hanging)),
|
||||
softWrap = true,
|
||||
modifier = Modifier.weight(1f).padding(end = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffToolbar(canJump: Boolean, onPreviousChange: () -> Unit, onNextChange: () -> Unit, onFileList: () -> Unit) {
|
||||
val label = stringResource(R.string.history_diff_tools)
|
||||
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||
Column(Modifier.navigationBarsPadding()) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Row(Modifier.fillMaxWidth().semantics { contentDescription = label }.padding(horizontal = 6.dp, vertical = 6.dp)) {
|
||||
DiffTool(R.drawable.ic_ub_chevron_up, R.string.history_diff_previous_change, canJump, Modifier.weight(1f), onPreviousChange)
|
||||
DiffTool(R.drawable.ic_ub_chevron_down, R.string.history_diff_next_change, canJump, Modifier.weight(1f), onNextChange)
|
||||
DiffTool(R.drawable.ic_ub_files, R.string.history_diff_file_list, true, Modifier.weight(1f), onFileList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffTool(@DrawableRes icon: Int, @StringRes label: Int, enabled: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
val color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline
|
||||
Column(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||
.heightIn(min = 52.dp)
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Box(Modifier.height(30.dp), contentAlignment = Alignment.Center) { UBIcon(icon, null, size = 22.dp, tint = color) }
|
||||
Text(stringResource(label), fontSize = 11.sp, fontWeight = FontWeight.Medium, color = color, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
private const val CHANGE_CONTEXT_ROWS = 2
|
||||
@ -1,70 +1,66 @@
|
||||
package com.ubisam.ubgitea.feature.history
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.ScrollableState
|
||||
import androidx.compose.foundation.gestures.rememberScrollableState
|
||||
import androidx.compose.foundation.gestures.scrollable
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.domain.history.CommitDetail
|
||||
import com.ubisam.ubgitea.domain.history.DiffLine
|
||||
import com.ubisam.ubgitea.domain.history.DiffRow
|
||||
import com.ubisam.ubgitea.domain.history.FileChange
|
||||
import com.ubisam.ubgitea.domain.history.FileChangeStatus
|
||||
import com.ubisam.ubgitea.domain.model.CommitSummary
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||
import com.ubisam.ubgitea.ui.SimplePaginationFooter
|
||||
import com.ubisam.ubgitea.ui.components.ActivityTone
|
||||
import com.ubisam.ubgitea.ui.components.Avatar
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.ListCard
|
||||
import com.ubisam.ubgitea.ui.components.MonoChip
|
||||
import com.ubisam.ubgitea.ui.components.SectionTitle
|
||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||
import com.ubisam.ubgitea.ui.formatInstant
|
||||
import kotlin.math.roundToInt
|
||||
import com.ubisam.ubgitea.ui.formatRelative
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/** True while one file's diff is open: shown full screen by [FileDiffScreen], outside the tab pager. */
|
||||
val HistoryUiState.showsFileDiff: Boolean
|
||||
get() = this is HistoryUiState.CommitDetailContent && selectedFile != null
|
||||
|
||||
/** The Commit tab: the commit list, and one commit's changed files. A file's diff opens in [FileDiffScreen]. */
|
||||
@Composable
|
||||
fun HistoryScreen(
|
||||
state: HistoryUiState,
|
||||
@ -75,131 +71,100 @@ fun HistoryScreen(
|
||||
onRetryDetail: () -> Unit,
|
||||
onRetryPatch: () -> Unit,
|
||||
onFileSelected: (FileChange) -> Unit,
|
||||
onClearFileSelection: () -> Unit,
|
||||
onBackToList: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onAuthenticationExpired: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
when (state) {
|
||||
HistoryUiState.Idle -> Unit
|
||||
is HistoryUiState.Loading -> {
|
||||
BackHandler(onBack = onBack)
|
||||
Header(state.target)
|
||||
CenterMessage { EstimatedProgressIndicator() }
|
||||
}
|
||||
is HistoryUiState.CommitList -> {
|
||||
BackHandler(onBack = onBack)
|
||||
Header(state.target)
|
||||
if (state.commits.isEmpty()) {
|
||||
CenterMessage { Text(stringResource(R.string.history_empty)) }
|
||||
} else {
|
||||
LazyColumn(Modifier.weight(1f)) {
|
||||
items(state.commits, key = CommitSummary::id) { commit ->
|
||||
CommitRow(commit) { onCommitSelected(commit) }
|
||||
}
|
||||
}
|
||||
}
|
||||
PageFooter(state.page, state.page > 1, state.hasNextPage, onPreviousPage, onNextPage)
|
||||
}
|
||||
is HistoryUiState.CommitDetailLoading -> {
|
||||
BackHandler(onBack = onBackToList)
|
||||
Header(state.target)
|
||||
CenterMessage { EstimatedProgressIndicator() }
|
||||
}
|
||||
is HistoryUiState.CommitDetailContent -> {
|
||||
CommitDetailScreen(state, onRetryPatch, onFileSelected, onClearFileSelection, onBackToList)
|
||||
}
|
||||
is HistoryUiState.Error -> {
|
||||
BackHandler(onBack = onBack)
|
||||
Header(state.target)
|
||||
CenterMessage {
|
||||
Text(stringResource(state.type.messageResource))
|
||||
if (state.type == HistoryError.AUTHENTICATION_EXPIRED) {
|
||||
Button(onClick = onAuthenticationExpired) { Text(stringResource(R.string.history_sign_in_again)) }
|
||||
} else {
|
||||
Button(onClick = if (state.context == HistoryErrorContext.LIST) onRetryList else onRetryDetail) {
|
||||
Text(stringResource(R.string.history_retry))
|
||||
}
|
||||
Column(modifier.fillMaxSize()) {
|
||||
when (state) {
|
||||
HistoryUiState.Idle -> Unit
|
||||
is HistoryUiState.Loading -> {
|
||||
BackHandler(onBack = onBack)
|
||||
CenterColumn { EstimatedProgressIndicator() }
|
||||
}
|
||||
is HistoryUiState.CommitList -> {
|
||||
BackHandler(onBack = onBack)
|
||||
if (state.commits.isEmpty()) {
|
||||
CenteredMessage(stringResource(R.string.history_empty))
|
||||
} else {
|
||||
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp)) {
|
||||
itemsIndexed(state.commits, key = { _, commit -> commit.id }) { index, commit ->
|
||||
CommitRow(commit, index == 0, index == state.commits.lastIndex) { onCommitSelected(commit) }
|
||||
}
|
||||
}
|
||||
SimplePaginationFooter(
|
||||
previousLabel = stringResource(R.string.history_previous_page),
|
||||
pageLabel = stringResource(R.string.history_page_number, state.page),
|
||||
nextLabel = stringResource(R.string.history_next_page),
|
||||
hasPrevious = state.page > 1,
|
||||
hasNext = state.hasNextPage,
|
||||
onPrevious = onPreviousPage,
|
||||
onNext = onNextPage,
|
||||
)
|
||||
}
|
||||
}
|
||||
is HistoryUiState.CommitDetailLoading -> {
|
||||
BackHandler(onBack = onBackToList)
|
||||
CenterColumn { EstimatedProgressIndicator() }
|
||||
}
|
||||
is HistoryUiState.CommitDetailContent -> {
|
||||
BackHandler(onBack = onBackToList)
|
||||
CommitDetailView(state, onRetryPatch, onFileSelected)
|
||||
}
|
||||
is HistoryUiState.Error -> {
|
||||
BackHandler(onBack = onBack)
|
||||
val expired = state.type == HistoryError.AUTHENTICATION_EXPIRED
|
||||
CenteredMessage(
|
||||
stringResource(state.type.messageResource),
|
||||
action = stringResource(if (expired) R.string.history_sign_in_again else R.string.history_retry),
|
||||
onAction = when {
|
||||
expired -> onAuthenticationExpired
|
||||
state.context == HistoryErrorContext.LIST -> onRetryList
|
||||
else -> onRetryDetail
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(target: HistoryTarget) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.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()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommitRow(commit: CommitSummary, onSelected: () -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(commit.shortId, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
|
||||
Text(commit.message, style = MaterialTheme.typography.titleMedium, maxLines = 2)
|
||||
Text(
|
||||
stringResource(R.string.history_commit_by_at, commit.authorName, formatInstant(commit.authoredAtEpochMillis)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// Below the "medium" width breakpoint (Android's own guidance for list-detail layouts, and
|
||||
// roughly a phone in any orientation), a selected file replaces the list as its own screen. At
|
||||
// or above it — a tablet, or a foldable's inner screen unfolded — the file list and the selected
|
||||
// file's diff show side by side at once, since there is room for both.
|
||||
private val WideLayoutBreakpoint = 600.dp
|
||||
|
||||
@Composable
|
||||
private fun CommitDetailScreen(
|
||||
state: HistoryUiState.CommitDetailContent,
|
||||
onRetryPatch: () -> Unit,
|
||||
onFileSelected: (FileChange) -> Unit,
|
||||
onClearFileSelection: () -> Unit,
|
||||
onBackToList: () -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
if (maxWidth >= WideLayoutBreakpoint) {
|
||||
BackHandler(onBack = onBackToList)
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Header(state.target)
|
||||
CommitDetailView(state, onRetryPatch, onFileSelected)
|
||||
}
|
||||
VerticalDivider()
|
||||
Column(Modifier.weight(1f)) {
|
||||
val selected = state.selectedFile
|
||||
if (selected != null) {
|
||||
FileDiffView(selected)
|
||||
} else {
|
||||
CenterMessage { Text(stringResource(R.string.history_file_diff_select_prompt)) }
|
||||
private fun CommitRow(commit: CommitSummary, isFirst: Boolean, isLast: Boolean, onSelected: () -> Unit) {
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (isFirst) 20.dp else 0.dp,
|
||||
topEnd = if (isFirst) 20.dp else 0.dp,
|
||||
bottomStart = if (isLast) 20.dp else 0.dp,
|
||||
bottomEnd = if (isLast) 20.dp else 0.dp,
|
||||
)
|
||||
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Avatar(commit.authorName)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
commit.message.lineSequence().firstOrNull().orEmpty(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MonoChip(commit.shortId, icon = R.drawable.ic_ub_commit)
|
||||
Text(
|
||||
stringResource(R.string.history_commit_by_at, commit.authorName, formatRelative(commit.authoredAtEpochMillis)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (state.selectedFile != null) {
|
||||
BackHandler(onBack = onClearFileSelection)
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Header(state.target)
|
||||
FileDiffView(state.selectedFile)
|
||||
}
|
||||
} else {
|
||||
BackHandler(onBack = onBackToList)
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Header(state.target)
|
||||
CommitDetailView(state, onRetryPatch, onFileSelected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -211,224 +176,122 @@ private fun CommitDetailView(
|
||||
onFileSelected: (FileChange) -> Unit,
|
||||
) {
|
||||
val canOpenFileDiff = state.patch is PatchUiState.Loaded
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Text(state.commit.id, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
|
||||
Text(state.commit.message, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.history_commit_by_at, state.commit.authorName, formatInstant(state.commit.authoredAtEpochMillis)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||
item(key = "commit") {
|
||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SelectionContainer { Text(state.commit.message.trim(), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MonoChip(state.commit.shortId, icon = R.drawable.ic_ub_commit)
|
||||
Text(
|
||||
stringResource(R.string.history_commit_by_at, state.commit.authorName, formatInstant(state.commit.authoredAtEpochMillis)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items(state.files, key = FileChange::path) { file ->
|
||||
item(key = "files-title") { SectionTitle(stringResource(R.string.history_files_changed, state.files.size)) }
|
||||
itemsIndexed(state.files, key = { _, file -> file.path }) { index, file ->
|
||||
FileChangeRow(
|
||||
file,
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
isFirst = index == 0,
|
||||
isLast = index == state.files.lastIndex,
|
||||
onSelected = if (canOpenFileDiff) ({ onFileSelected(file) }) else null,
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider(Modifier.padding(vertical = 12.dp)) }
|
||||
item { PatchSection(state.patch, onRetryPatch) }
|
||||
item(key = "patch-title") { SectionTitle(stringResource(R.string.history_patch_title), Modifier.padding(top = 8.dp)) }
|
||||
item(key = "patch") { PatchSection(state.patch, onRetryPatch) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileChangeRow(file: FileChange, modifier: Modifier = Modifier, onSelected: (() -> Unit)? = null) {
|
||||
Column(
|
||||
if (onSelected != null) modifier.clickable(role = Role.Button, onClick = onSelected) else modifier,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(file.status.labelResource), style = MaterialTheme.typography.bodySmall)
|
||||
Text(file.path, style = MaterialTheme.typography.bodySmall, maxLines = 1)
|
||||
}
|
||||
file.previousPath?.let {
|
||||
Text(stringResource(R.string.history_renamed_from, it), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileDiffView(state: HistoryUiState.SelectedFileDiff) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Text(state.file.path, style = MaterialTheme.typography.titleMedium)
|
||||
if (state.isTruncated) Text(stringResource(R.string.history_patch_truncated), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (state.rows.isEmpty()) {
|
||||
CenterMessage { Text(stringResource(R.string.history_file_diff_unavailable)) }
|
||||
} else {
|
||||
// Both lists always have exactly state.rows.size items (a missing side just renders
|
||||
// a blank line for that index), so mirroring first-visible-index/offset between them
|
||||
// keeps the two panels showing the same rows without needing per-line height math.
|
||||
val beforeState = rememberLazyListState()
|
||||
val afterState = rememberLazyListState()
|
||||
// One shared horizontal scroll offset for every line in both panels, driven manually
|
||||
// instead of via Modifier.horizontalScroll per row: sharing a single ScrollState across
|
||||
// many differently-sized Text rows makes each row's layout pass overwrite the shared
|
||||
// max-scroll bound with its own width, so as the list recomposes different-length lines
|
||||
// the bound (and therefore the visible offset) kept getting stomped mid-drag, snapping
|
||||
// the text back and forth. Computing one fixed bound up front and applying a plain
|
||||
// Modifier.offset (clamped by hand) avoids that thrashing entirely.
|
||||
val diffLineStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val maxHorizontalOffsetPx = remember(state.rows, diffLineStyle) {
|
||||
val longestLineChars = state.rows.maxOfOrNull { row ->
|
||||
maxOf(row.left?.text?.length ?: 0, row.right?.text?.length ?: 0)
|
||||
} ?: 0
|
||||
if (longestLineChars == 0) {
|
||||
0f
|
||||
} else {
|
||||
// Monospace, so any character stands in for the widest line's actual glyphs;
|
||||
// +2 accounts for the "- "/"+ "/" " prefix DiffSideLine prepends below.
|
||||
textMeasurer.measure("0".repeat(longestLineChars + 2), diffLineStyle).size.width.toFloat()
|
||||
private fun FileChangeRow(file: FileChange, isFirst: Boolean, isLast: Boolean, onSelected: (() -> Unit)?) {
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (isFirst) 20.dp else 0.dp,
|
||||
topEnd = if (isFirst) 20.dp else 0.dp,
|
||||
bottomStart = if (isLast) 20.dp else 0.dp,
|
||||
bottomEnd = if (isLast) 20.dp else 0.dp,
|
||||
)
|
||||
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
|
||||
Column {
|
||||
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.then(if (onSelected != null) Modifier.clickable(role = Role.Button, onClick = onSelected) else Modifier)
|
||||
.heightIn(min = 52.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatusBadge(stringResource(file.status.labelResource), file.status.tone)
|
||||
Text(
|
||||
file.path.substringAfterLast('/'),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
val horizontalOffsetPx = remember(state.rows) { mutableFloatStateOf(0f) }
|
||||
// Dragging left must reveal what's further along the line (offset increases), matching
|
||||
// every other horizontally-scrollable surface on Android — verified live to be backwards
|
||||
// with a plain "+ delta", so the sign is flipped here instead.
|
||||
val horizontalScrollableState = rememberScrollableState { delta ->
|
||||
val previousOffset = horizontalOffsetPx.floatValue
|
||||
val newOffset = (previousOffset - delta).coerceIn(0f, maxHorizontalOffsetPx)
|
||||
horizontalOffsetPx.floatValue = newOffset
|
||||
previousOffset - newOffset
|
||||
}
|
||||
LaunchedEffect(beforeState, afterState) {
|
||||
snapshotFlow { beforeState.firstVisibleItemIndex to beforeState.firstVisibleItemScrollOffset }
|
||||
.collect { (index, offset) ->
|
||||
if (afterState.firstVisibleItemIndex != index || afterState.firstVisibleItemScrollOffset != offset) {
|
||||
afterState.scrollToItem(index, offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(beforeState, afterState) {
|
||||
snapshotFlow { afterState.firstVisibleItemIndex to afterState.firstVisibleItemScrollOffset }
|
||||
.collect { (index, offset) ->
|
||||
if (beforeState.firstVisibleItemIndex != index || beforeState.firstVisibleItemScrollOffset != offset) {
|
||||
beforeState.scrollToItem(index, offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.history_file_diff_before),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
LazyColumn(Modifier.weight(1f), state = beforeState) {
|
||||
items(state.rows.size) { index ->
|
||||
DiffSideLine(state.rows[index].left, state.rows[index].isGap, horizontalScrollableState, horizontalOffsetPx)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 2.dp)
|
||||
Text(
|
||||
stringResource(R.string.history_file_diff_after),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
LazyColumn(Modifier.weight(1f), state = afterState) {
|
||||
items(state.rows.size) { index ->
|
||||
DiffSideLine(state.rows[index].right, state.rows[index].isGap, horizontalScrollableState, horizontalOffsetPx)
|
||||
Text(
|
||||
file.path,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
)
|
||||
file.previousPath?.let {
|
||||
Text(
|
||||
stringResource(R.string.history_renamed_from, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DiffRow.isGap: Boolean
|
||||
get() = left == null && right == null
|
||||
|
||||
@Composable
|
||||
private fun DiffSideLine(
|
||||
line: DiffLine?,
|
||||
isGap: Boolean,
|
||||
scrollableState: ScrollableState,
|
||||
horizontalOffsetPx: State<Float>,
|
||||
) {
|
||||
if (isGap) {
|
||||
Text(
|
||||
"⋯",
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
return
|
||||
}
|
||||
val color = when (line) {
|
||||
is DiffLine.Removed -> MaterialTheme.colorScheme.error
|
||||
is DiffLine.Added -> DiffAddedColor
|
||||
else -> Color.Unspecified
|
||||
}
|
||||
val prefix = when (line) {
|
||||
is DiffLine.Removed -> "- "
|
||||
is DiffLine.Added -> "+ "
|
||||
else -> " "
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxWidth().clipToBounds()
|
||||
.scrollable(state = scrollableState, orientation = Orientation.Horizontal)
|
||||
.padding(vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
prefix + (line?.text ?: ""),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = color,
|
||||
softWrap = false,
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(-horizontalOffsetPx.value.roundToInt(), 0) }
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Material3 has no built-in "success" role to match colorScheme.error, so this is hardcoded.
|
||||
private val DiffAddedColor = Color(0xFF2E7D32)
|
||||
|
||||
@Composable
|
||||
private fun PatchSection(patch: PatchUiState, onRetry: () -> Unit) {
|
||||
when (patch) {
|
||||
PatchUiState.Loading -> CenterMessage { EstimatedProgressIndicator() }
|
||||
is PatchUiState.Loaded -> PatchText(patch.text, patch.isTruncated)
|
||||
PatchUiState.Failed -> CenterMessage {
|
||||
Text(stringResource(R.string.history_patch_failed))
|
||||
Button(onClick = onRetry) { Text(stringResource(R.string.history_retry)) }
|
||||
PatchUiState.Loading -> CenterColumn(Modifier.padding(vertical = 24.dp)) { EstimatedProgressIndicator() }
|
||||
is PatchUiState.Loaded -> ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
if (patch.isTruncated) {
|
||||
Text(
|
||||
stringResource(R.string.history_patch_truncated),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
SelectionContainer {
|
||||
Text(
|
||||
patch.text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
PatchUiState.Failed -> CenteredMessage(
|
||||
stringResource(R.string.history_patch_failed),
|
||||
Modifier.padding(vertical = 8.dp),
|
||||
action = stringResource(R.string.history_retry),
|
||||
onAction = onRetry,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PatchText(text: String, isTruncated: Boolean) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||
if (isTruncated) Text(stringResource(R.string.history_patch_truncated))
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PageFooter(page: Int, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) {
|
||||
SimplePaginationFooter(
|
||||
previousLabel = stringResource(R.string.history_previous_page),
|
||||
pageLabel = stringResource(R.string.history_page_number, page),
|
||||
nextLabel = stringResource(R.string.history_next_page),
|
||||
hasPrevious = hasPrevious,
|
||||
hasNext = hasNext,
|
||||
onPrevious = onPrevious,
|
||||
onNext = onNext,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterMessage(content: @Composable ColumnScope.() -> Unit) {
|
||||
internal fun CenterColumn(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(24.dp),
|
||||
modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
content = content,
|
||||
@ -459,12 +322,12 @@ private fun EstimatedProgressIndicator() {
|
||||
Text(
|
||||
stringResource(R.string.history_loading_progress, (progress * 100).toInt()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
modifier = Modifier.padding(top = 8.dp).semantics { heading() },
|
||||
)
|
||||
}
|
||||
|
||||
private val FileChangeStatus.labelResource: Int
|
||||
get() = when (this) {
|
||||
internal val FileChangeStatus.labelResource: Int
|
||||
@StringRes get() = when (this) {
|
||||
FileChangeStatus.ADDED -> R.string.history_status_added
|
||||
FileChangeStatus.MODIFIED -> R.string.history_status_modified
|
||||
FileChangeStatus.DELETED -> R.string.history_status_deleted
|
||||
@ -472,8 +335,17 @@ private val FileChangeStatus.labelResource: Int
|
||||
FileChangeStatus.OTHER -> R.string.history_status_other
|
||||
}
|
||||
|
||||
private val HistoryError.messageResource: Int
|
||||
internal val FileChangeStatus.tone: ActivityTone
|
||||
get() = when (this) {
|
||||
FileChangeStatus.ADDED -> ActivityTone.SUCCESS
|
||||
FileChangeStatus.MODIFIED -> ActivityTone.BRAND
|
||||
FileChangeStatus.DELETED -> ActivityTone.DANGER
|
||||
FileChangeStatus.RENAMED -> ActivityTone.MERGED
|
||||
FileChangeStatus.OTHER -> ActivityTone.NEUTRAL
|
||||
}
|
||||
|
||||
private val HistoryError.messageResource: Int
|
||||
@StringRes get() = when (this) {
|
||||
HistoryError.OFFLINE -> R.string.history_offline
|
||||
HistoryError.AUTHENTICATION_EXPIRED -> R.string.history_auth_expired
|
||||
HistoryError.PERMISSION_DENIED -> R.string.history_permission_denied
|
||||
|
||||
@ -12,6 +12,7 @@ import com.ubisam.ubgitea.core.settings.AppSettingsSession
|
||||
import com.ubisam.ubgitea.data.history.GiteaHistoryApi
|
||||
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
|
||||
import com.ubisam.ubgitea.domain.history.CommitDetail
|
||||
import com.ubisam.ubgitea.domain.history.DiffEntry
|
||||
import com.ubisam.ubgitea.domain.history.DiffRow
|
||||
import com.ubisam.ubgitea.domain.history.FileChange
|
||||
import com.ubisam.ubgitea.domain.history.HistoryRepository
|
||||
@ -75,8 +76,12 @@ sealed interface HistoryUiState {
|
||||
val file: FileChange,
|
||||
rows: List<DiffRow>,
|
||||
val isTruncated: Boolean,
|
||||
entries: List<DiffEntry> = emptyList(),
|
||||
) {
|
||||
val rows = Collections.unmodifiableList(rows.toList())
|
||||
|
||||
/** [rows] as shown: numbered, with a header row for each hunk. */
|
||||
val entries: List<DiffEntry> = Collections.unmodifiableList(entries.toList())
|
||||
}
|
||||
data class Error(
|
||||
val target: HistoryTarget,
|
||||
@ -152,7 +157,16 @@ class HistoryViewModel(
|
||||
val current = mutableState.value as? HistoryUiState.CommitDetailContent ?: return
|
||||
val patch = current.patch as? PatchUiState.Loaded ?: return
|
||||
val rows = UnifiedDiffParser.parseFile(patch.text, file.path)
|
||||
mutableState.value = current.withSelectedFile(HistoryUiState.SelectedFileDiff(file, rows, patch.isTruncated))
|
||||
val entries = UnifiedDiffParser.parseFileEntries(patch.text, file.path)
|
||||
mutableState.value = current.withSelectedFile(HistoryUiState.SelectedFileDiff(file, rows, patch.isTruncated, entries))
|
||||
}
|
||||
|
||||
/** Moves the open diff to the file [delta] places along the commit's file list, if there is one. */
|
||||
fun openAdjacentFileDiff(delta: Int) {
|
||||
val current = mutableState.value as? HistoryUiState.CommitDetailContent ?: return
|
||||
val selected = current.selectedFile ?: return
|
||||
val index = current.files.indexOfFirst { it.path == selected.file.path }
|
||||
current.files.getOrNull(index + delta)?.takeIf { index >= 0 }?.let(::openFileDiff)
|
||||
}
|
||||
|
||||
fun clearFileSelection() {
|
||||
|
||||
641
app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt
Normal file
641
app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt
Normal file
@ -0,0 +1,641 @@
|
||||
package com.ubisam.ubgitea.feature.home
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityCategory
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityItem
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityKind
|
||||
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkScope
|
||||
import com.ubisam.ubgitea.ui.components.ActivityTone
|
||||
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.KindTile
|
||||
import com.ubisam.ubgitea.ui.components.ListCard
|
||||
import com.ubisam.ubgitea.ui.components.MonoChip
|
||||
import com.ubisam.ubgitea.ui.components.SectionTitle
|
||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||
import com.ubisam.ubgitea.ui.components.TabTitle
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import com.ubisam.ubgitea.ui.formatHeadlineDate
|
||||
import com.ubisam.ubgitea.ui.formatRelative
|
||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||
|
||||
/** Where a tap on the home screen leads. The screen names the place; the activity navigates. */
|
||||
sealed interface HomeDestination {
|
||||
data object Repositories : HomeDestination
|
||||
data class Work(val scope: WorkScope) : HomeDestination
|
||||
data class Branch(val owner: String, val name: String, val ref: String, val isTag: Boolean) : HomeDestination
|
||||
data class WorkItem(val owner: String, val name: String, val number: Long, val isPullRequest: Boolean) : HomeDestination
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
state: HomeUiState,
|
||||
accountName: String?,
|
||||
instanceHost: String,
|
||||
isWide: Boolean,
|
||||
onNavigate: (HomeDestination) -> Unit,
|
||||
onSelectOrganization: (String?) -> Unit,
|
||||
onSelectFilter: (ActivityCategory?) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onViewSettings: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onAuthenticationExpired: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier.fillMaxSize()) {
|
||||
BrandTopBar(accountName, instanceHost, onViewSettings, onLogout) {
|
||||
IconButton(onClick = onRefresh) { UBIcon(R.drawable.ic_ub_refresh, stringResource(R.string.home_refresh)) }
|
||||
}
|
||||
when (state) {
|
||||
HomeUiState.Idle, HomeUiState.Loading -> CenteredLoading(stringResource(R.string.home_loading))
|
||||
is HomeUiState.Error -> {
|
||||
val expired = state.type == HomeError.AUTHENTICATION_EXPIRED
|
||||
CenteredMessage(
|
||||
stringResource(state.type.messageResource),
|
||||
action = stringResource(if (expired) R.string.home_sign_in_again else R.string.home_retry),
|
||||
onAction = if (expired) onAuthenticationExpired else onRefresh,
|
||||
)
|
||||
}
|
||||
is HomeUiState.Content -> HomeContent(state, isWide, onNavigate, onSelectOrganization, onSelectFilter, onLoadMore, onRefresh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun HomeContent(
|
||||
state: HomeUiState.Content,
|
||||
isWide: Boolean,
|
||||
onNavigate: (HomeDestination) -> Unit,
|
||||
onSelectOrganization: (String?) -> Unit,
|
||||
onSelectFilter: (ActivityCategory?) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||
if (isWide) {
|
||||
// Unfolded: the overview stays put on the left while the timeline scrolls on the right,
|
||||
// so the whole picture is on screen at once.
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.weight(0.46f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
overview(state, vertical = true, onNavigate, onSelectOrganization)
|
||||
}
|
||||
LazyColumn(Modifier.weight(0.54f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
timeline(state, onNavigate, onSelectFilter, onLoadMore)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
overview(state, vertical = false, onNavigate, onSelectOrganization)
|
||||
timeline(state, onNavigate, onSelectFilter, onLoadMore)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.overview(
|
||||
state: HomeUiState.Content,
|
||||
vertical: Boolean,
|
||||
onNavigate: (HomeDestination) -> Unit,
|
||||
onSelectOrganization: (String?) -> Unit,
|
||||
) {
|
||||
item(key = "headline") {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(bottom = 20.dp)) {
|
||||
TabTitle(
|
||||
stringResource(R.string.home_title),
|
||||
subtitle = stringResource(R.string.home_subtitle, formatHeadlineDate(state.today)),
|
||||
)
|
||||
if (state.organizations.isNotEmpty()) {
|
||||
OrganizationChips(state.organization, state.organizations, onSelectOrganization)
|
||||
}
|
||||
SummaryTiles(state.summary, onNavigate)
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "attention") { AttentionCard(state.summary, onNavigate) }
|
||||
if (state.activeRepositories.isNotEmpty()) {
|
||||
item(key = "active-title") {
|
||||
SectionTitle(stringResource(R.string.home_active_repositories)) {
|
||||
TextButton(onClick = { onNavigate(HomeDestination.Repositories) }) {
|
||||
Text(stringResource(R.string.home_active_repositories_all))
|
||||
UBIcon(R.drawable.ic_ub_chevron_right, null, size = 16.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (vertical) {
|
||||
item(key = "active-list") {
|
||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||
state.activeRepositories.forEachIndexed { index, repository ->
|
||||
if (index > 0) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
ActiveRepositoryRow(repository, onNavigate)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item(key = "active-row") {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(state.activeRepositories, key = { "${it.repository.owner}/${it.repository.name}" }) { repository ->
|
||||
ActiveRepositoryCard(repository, onNavigate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.timeline(
|
||||
state: HomeUiState.Content,
|
||||
onNavigate: (HomeDestination) -> Unit,
|
||||
onSelectFilter: (ActivityCategory?) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
) {
|
||||
item(key = "timeline-title") { SectionTitle(stringResource(R.string.home_timeline), Modifier.padding(top = 8.dp)) }
|
||||
item(key = "timeline-filter") { FilterChips(state.filter, onSelectFilter) }
|
||||
val visible = state.visibleTimeline
|
||||
if (visible.isEmpty()) {
|
||||
item(key = "timeline-empty") {
|
||||
Text(
|
||||
stringResource(if (state.timeline.isEmpty()) R.string.home_timeline_empty else R.string.home_timeline_filter_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Each row is its own lazy item, so a long timeline only composes what is on screen. The card
|
||||
// look comes from the rows' shared background rather than from one enclosing card.
|
||||
items(visible.size, key = { visible[it].id }) { index ->
|
||||
TimelineRow(
|
||||
item = visible[index],
|
||||
isFirst = index == 0,
|
||||
isLast = index == visible.lastIndex && state.nextPage == null,
|
||||
onNavigate = onNavigate,
|
||||
)
|
||||
}
|
||||
if (state.nextPage != null) {
|
||||
item(key = "timeline-more") {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(
|
||||
topStart = if (visible.isEmpty()) 20.dp else 0.dp,
|
||||
topEnd = if (visible.isEmpty()) 20.dp else 0.dp,
|
||||
bottomStart = 20.dp,
|
||||
bottomEnd = 20.dp,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
|
||||
) {
|
||||
TextButton(onClick = onLoadMore, enabled = !state.isLoadingMore, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
|
||||
Text(
|
||||
stringResource(
|
||||
when {
|
||||
state.isLoadingMore -> R.string.home_load_more_loading
|
||||
state.loadMoreFailed -> R.string.home_load_more_failed
|
||||
else -> R.string.home_load_more
|
||||
},
|
||||
),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OrganizationChips(selected: String?, organizations: List<OrganizationSummary>, onSelect: (String?) -> Unit) {
|
||||
val label = stringResource(R.string.home_source_label)
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.semantics { contentDescription = label }
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterChip(selected == null, { onSelect(null) }, { Text(stringResource(R.string.home_source_mine)) })
|
||||
organizations.forEach { organization ->
|
||||
FilterChip(
|
||||
selected == organization.name,
|
||||
{ onSelect(organization.name) },
|
||||
{ Text(organization.displayName?.takeIf(String::isNotBlank) ?: organization.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryTiles(summary: HomeSummary, onNavigate: (HomeDestination) -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SummaryTile(
|
||||
R.drawable.ic_ub_commit, R.string.home_stat_pushes, summary.todayPushes, summary.todayIsLowerBound,
|
||||
Modifier.weight(1f),
|
||||
) { onNavigate(HomeDestination.Repositories) }
|
||||
SummaryTile(
|
||||
R.drawable.ic_ub_pull_request, R.string.home_stat_open_pull_requests, summary.openPullRequests, false,
|
||||
Modifier.weight(1f),
|
||||
) { onNavigate(HomeDestination.Work(WorkScope.EVERYONE)) }
|
||||
SummaryTile(
|
||||
R.drawable.ic_ub_issue, R.string.home_stat_issues, summary.todayIssuesOpened, summary.todayIsLowerBound,
|
||||
Modifier.weight(1f),
|
||||
) { onNavigate(HomeDestination.Work(WorkScope.EVERYONE)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryTile(
|
||||
@DrawableRes icon: Int,
|
||||
@StringRes label: Int,
|
||||
value: Int?,
|
||||
isLowerBound: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val labelText = stringResource(label)
|
||||
val valueText = when {
|
||||
value == null -> stringResource(R.string.home_stat_unknown)
|
||||
isLowerBound -> stringResource(R.string.home_stat_at_least, value)
|
||||
else -> value.toString()
|
||||
}
|
||||
val description = stringResource(R.string.home_stat_description, labelText, valueText)
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
modifier = modifier.clearAndSetSemantics {
|
||||
contentDescription = description
|
||||
role = Role.Button
|
||||
},
|
||||
) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
UBIcon(icon, null, size = 20.dp)
|
||||
Text(valueText, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(labelText, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttentionCard(summary: HomeSummary, onNavigate: (HomeDestination) -> Unit) {
|
||||
val count = summary.attentionCount ?: return
|
||||
// Review requests come first: someone is waiting on them.
|
||||
val scope = if ((summary.reviewRequested ?: 0) > 0 || (summary.assignedIssues ?: 0) == 0) {
|
||||
WorkScope.REVIEW_REQUESTED
|
||||
} else {
|
||||
WorkScope.ASSIGNED_TO_ME
|
||||
}
|
||||
val needsAttention = count > 0
|
||||
Surface(
|
||||
onClick = { onNavigate(HomeDestination.Work(scope)) },
|
||||
color = if (needsAttention) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
|
||||
contentColor = if (needsAttention) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(top = 16.dp),
|
||||
) {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
UBIcon(if (needsAttention) R.drawable.ic_ub_eye else R.drawable.ic_ub_check, null)
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(
|
||||
if (needsAttention) stringResource(R.string.home_attention_title, count) else stringResource(R.string.home_attention_none),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (needsAttention) {
|
||||
Text(
|
||||
stringResource(R.string.home_attention_detail, summary.reviewRequested ?: 0, summary.assignedIssues ?: 0),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
UBIcon(R.drawable.ic_ub_chevron_right, null, size = 20.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveRepositoryCard(repository: ActiveRepository, onNavigate: (HomeDestination) -> Unit) {
|
||||
val destination = repository.destination()
|
||||
ListCard(
|
||||
Modifier.width(208.dp).clip(RoundedCornerShape(20.dp))
|
||||
.then(if (destination != null) Modifier.clickable(role = Role.Button) { onNavigate(destination) } else Modifier),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
repository.repository.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (repository.latestRefName != null) MonoChip(repository.latestRefName, icon = R.drawable.ic_ub_branch)
|
||||
ActivityBars(repository.dailyCounts, Modifier.fillMaxWidth().height(32.dp))
|
||||
Text(
|
||||
stringResource(R.string.home_active_repository_footer, formatRelative(repository.latestAtEpochMillis), repository.latestActor),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveRepositoryRow(repository: ActiveRepository, onNavigate: (HomeDestination) -> Unit) {
|
||||
val destination = repository.destination()
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.then(if (destination != null) Modifier.clickable(role = Role.Button) { onNavigate(destination) } else Modifier)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(
|
||||
repository.repository.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (repository.latestRefName != null) MonoChip(repository.latestRefName, icon = R.drawable.ic_ub_branch)
|
||||
Text(
|
||||
stringResource(R.string.home_active_repository_footer, formatRelative(repository.latestAtEpochMillis), repository.latestActor),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
ActivityBars(repository.dailyCounts, Modifier.width(64.dp).height(30.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun ActiveRepository.destination(): HomeDestination? =
|
||||
latestRefName?.let { HomeDestination.Branch(repository.owner, repository.name, it, isTag = false) }
|
||||
|
||||
/** Seven bars, oldest day first. Heights are relative to this repository's own busiest day. */
|
||||
@Composable
|
||||
private fun ActivityBars(counts: List<Int>, modifier: Modifier = Modifier) {
|
||||
val highest = counts.maxOrNull()?.coerceAtLeast(1) ?: 1
|
||||
val description = stringResource(R.string.home_activity_bars_description, counts.sum())
|
||||
Row(
|
||||
modifier.clearAndSetSemantics { contentDescription = description },
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
counts.forEach { count ->
|
||||
val fraction = if (count == 0) MIN_BAR_FRACTION else (count.toFloat() / highest).coerceAtLeast(MIN_BAR_FRACTION)
|
||||
Box(
|
||||
Modifier.weight(1f)
|
||||
.fillMaxHeight(fraction)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(if (count == 0) MaterialTheme.colorScheme.outlineVariant else UBGiteaTheme.colors.brand),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MIN_BAR_FRACTION = 0.12f
|
||||
|
||||
@Composable
|
||||
private fun FilterChips(selected: ActivityCategory?, onSelect: (ActivityCategory?) -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp).padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterChip(selected == null, { onSelect(null) }, { Text(stringResource(R.string.home_filter_all)) })
|
||||
FilterChip(selected == ActivityCategory.PUSH, { onSelect(ActivityCategory.PUSH) }, { Text(stringResource(R.string.home_filter_push)) })
|
||||
FilterChip(
|
||||
selected == ActivityCategory.PULL_REQUEST,
|
||||
{ onSelect(ActivityCategory.PULL_REQUEST) },
|
||||
{ Text(stringResource(R.string.home_filter_pull_request)) },
|
||||
)
|
||||
FilterChip(selected == ActivityCategory.ISSUE, { onSelect(ActivityCategory.ISSUE) }, { Text(stringResource(R.string.home_filter_issue)) })
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun TimelineRow(item: ActivityItem, isFirst: Boolean, isLast: Boolean, onNavigate: (HomeDestination) -> Unit) {
|
||||
val destination = item.destination()
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (isFirst) 20.dp else 0.dp,
|
||||
topEnd = if (isFirst) 20.dp else 0.dp,
|
||||
bottomStart = if (isLast) 20.dp else 0.dp,
|
||||
bottomEnd = if (isLast) 20.dp else 0.dp,
|
||||
)
|
||||
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
|
||||
Column {
|
||||
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.then(if (destination != null) Modifier.clickable(role = Role.Button) { onNavigate(destination) } else Modifier)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
KindTile(item.kind.icon, item.kind.tone)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
item.actorLogin,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
StatusBadge(stringResource(item.kind.label), item.kind.tone)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
formatRelative(item.createdAtEpochMillis),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
ActivityHeadline(item)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
item.repository.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (item.refName != null) {
|
||||
val tagged = item.kind == ActivityKind.TAG_PUSHED || item.kind == ActivityKind.TAG_DELETED
|
||||
MonoChip(item.refName, icon = if (tagged) R.drawable.ic_ub_tag else R.drawable.ic_ub_branch)
|
||||
}
|
||||
item.commits.firstOrNull()?.let { MonoChip(it.shortId, icon = R.drawable.ic_ub_commit) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActivityHeadline(item: ActivityItem) {
|
||||
val firstCommit = item.commits.firstOrNull()
|
||||
val text = when {
|
||||
firstCommit != null && firstCommit.message.isNotBlank() -> {
|
||||
val more = item.commitCount - 1
|
||||
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val suffix = if (more > 0) stringResource(R.string.home_more_commits, more) else null
|
||||
buildAnnotatedString {
|
||||
append(firstCommit.message)
|
||||
if (suffix != null) {
|
||||
append(' ')
|
||||
withStyle(SpanStyle(color = muted, fontWeight = FontWeight.Normal)) { append(suffix) }
|
||||
}
|
||||
}
|
||||
}
|
||||
item.kind == ActivityKind.PUSH && item.commitCount > 0 ->
|
||||
buildAnnotatedString { append(stringResource(R.string.home_commit_count, item.commitCount)) }
|
||||
item.subjectNumber != null && item.subjectText != null ->
|
||||
buildAnnotatedString { append(stringResource(R.string.home_subject_number, item.subjectNumber, item.subjectText)) }
|
||||
item.subjectNumber != null ->
|
||||
buildAnnotatedString { append(stringResource(R.string.home_subject_number_only, item.subjectNumber)) }
|
||||
item.subjectText != null -> buildAnnotatedString { append(item.subjectText) }
|
||||
else -> return
|
||||
}
|
||||
Text(text, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
private fun ActivityItem.destination(): HomeDestination? = when (kind.category) {
|
||||
ActivityCategory.PUSH -> when (kind) {
|
||||
ActivityKind.PUSH -> refName?.let { HomeDestination.Branch(repository.owner, repository.name, it, isTag = false) }
|
||||
ActivityKind.TAG_PUSHED -> refName?.let { HomeDestination.Branch(repository.owner, repository.name, it, isTag = true) }
|
||||
// A deleted branch or tag has nothing left to open.
|
||||
else -> null
|
||||
}
|
||||
ActivityCategory.PULL_REQUEST ->
|
||||
subjectNumber?.let { HomeDestination.WorkItem(repository.owner, repository.name, it, isPullRequest = true) }
|
||||
ActivityCategory.ISSUE ->
|
||||
subjectNumber?.let { HomeDestination.WorkItem(repository.owner, repository.name, it, isPullRequest = false) }
|
||||
ActivityCategory.OTHER -> null
|
||||
}
|
||||
|
||||
private val ActivityKind.icon: Int
|
||||
@DrawableRes get() = when (this) {
|
||||
ActivityKind.PUSH -> R.drawable.ic_ub_commit
|
||||
ActivityKind.TAG_PUSHED, ActivityKind.TAG_DELETED -> R.drawable.ic_ub_tag
|
||||
ActivityKind.BRANCH_DELETED -> R.drawable.ic_ub_branch
|
||||
ActivityKind.PULL_REQUEST_MERGED -> R.drawable.ic_ub_merge
|
||||
ActivityKind.PULL_REQUEST_APPROVED -> R.drawable.ic_ub_check
|
||||
ActivityKind.PULL_REQUEST_COMMENTED, ActivityKind.ISSUE_COMMENTED -> R.drawable.ic_ub_comment
|
||||
ActivityKind.PULL_REQUEST_OPENED, ActivityKind.PULL_REQUEST_CLOSED, ActivityKind.PULL_REQUEST_REOPENED,
|
||||
ActivityKind.PULL_REQUEST_CHANGES_REQUESTED,
|
||||
-> R.drawable.ic_ub_pull_request
|
||||
ActivityKind.ISSUE_OPENED, ActivityKind.ISSUE_CLOSED, ActivityKind.ISSUE_REOPENED -> R.drawable.ic_ub_issue
|
||||
ActivityKind.RELEASE_PUBLISHED -> R.drawable.ic_ub_tag
|
||||
ActivityKind.REPOSITORY_CREATED -> R.drawable.ic_ub_repo
|
||||
ActivityKind.OTHER -> R.drawable.ic_ub_list
|
||||
}
|
||||
|
||||
private val ActivityKind.tone: ActivityTone
|
||||
get() = when (this) {
|
||||
ActivityKind.PUSH, ActivityKind.TAG_PUSHED, ActivityKind.RELEASE_PUBLISHED, ActivityKind.REPOSITORY_CREATED -> ActivityTone.BRAND
|
||||
ActivityKind.PULL_REQUEST_OPENED, ActivityKind.PULL_REQUEST_REOPENED, ActivityKind.PULL_REQUEST_APPROVED,
|
||||
ActivityKind.ISSUE_OPENED, ActivityKind.ISSUE_REOPENED,
|
||||
-> ActivityTone.SUCCESS
|
||||
ActivityKind.PULL_REQUEST_MERGED -> ActivityTone.MERGED
|
||||
ActivityKind.PULL_REQUEST_CLOSED, ActivityKind.ISSUE_CLOSED, ActivityKind.BRANCH_DELETED, ActivityKind.TAG_DELETED -> ActivityTone.DANGER
|
||||
ActivityKind.PULL_REQUEST_CHANGES_REQUESTED -> ActivityTone.WARNING
|
||||
ActivityKind.PULL_REQUEST_COMMENTED, ActivityKind.ISSUE_COMMENTED, ActivityKind.OTHER -> ActivityTone.NEUTRAL
|
||||
}
|
||||
|
||||
private val ActivityKind.label: Int
|
||||
@StringRes get() = when (this) {
|
||||
ActivityKind.PUSH -> R.string.home_kind_push
|
||||
ActivityKind.TAG_PUSHED -> R.string.home_kind_tag_pushed
|
||||
ActivityKind.BRANCH_DELETED -> R.string.home_kind_branch_deleted
|
||||
ActivityKind.TAG_DELETED -> R.string.home_kind_tag_deleted
|
||||
ActivityKind.PULL_REQUEST_OPENED -> R.string.home_kind_pull_request_opened
|
||||
ActivityKind.PULL_REQUEST_MERGED -> R.string.home_kind_pull_request_merged
|
||||
ActivityKind.PULL_REQUEST_CLOSED -> R.string.home_kind_pull_request_closed
|
||||
ActivityKind.PULL_REQUEST_REOPENED -> R.string.home_kind_pull_request_reopened
|
||||
ActivityKind.PULL_REQUEST_APPROVED -> R.string.home_kind_pull_request_approved
|
||||
ActivityKind.PULL_REQUEST_CHANGES_REQUESTED -> R.string.home_kind_pull_request_changes_requested
|
||||
ActivityKind.PULL_REQUEST_COMMENTED -> R.string.home_kind_pull_request_commented
|
||||
ActivityKind.ISSUE_OPENED -> R.string.home_kind_issue_opened
|
||||
ActivityKind.ISSUE_CLOSED -> R.string.home_kind_issue_closed
|
||||
ActivityKind.ISSUE_REOPENED -> R.string.home_kind_issue_reopened
|
||||
ActivityKind.ISSUE_COMMENTED -> R.string.home_kind_issue_commented
|
||||
ActivityKind.RELEASE_PUBLISHED -> R.string.home_kind_release_published
|
||||
ActivityKind.REPOSITORY_CREATED -> R.string.home_kind_repository_created
|
||||
ActivityKind.OTHER -> R.string.home_kind_other
|
||||
}
|
||||
|
||||
private val HomeError.messageResource: Int
|
||||
@StringRes get() = when (this) {
|
||||
HomeError.OFFLINE -> R.string.home_offline
|
||||
HomeError.AUTHENTICATION_EXPIRED -> R.string.home_auth_expired
|
||||
HomeError.PERMISSION_DENIED -> R.string.home_permission_denied
|
||||
HomeError.SERVER -> R.string.home_server_error
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
package com.ubisam.ubgitea.feature.home
|
||||
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityItem
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityKind
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
/** A repository that has had recent activity, with how that activity spread over the last days. */
|
||||
data class ActiveRepository(
|
||||
val repository: RepositoryCoordinates,
|
||||
val latestRefName: String?,
|
||||
val latestActor: String,
|
||||
val latestAtEpochMillis: Long,
|
||||
/** Activity count per day, oldest first, ending today. */
|
||||
val dailyCounts: List<Int>,
|
||||
)
|
||||
|
||||
data class TodayCounts(val pushes: Int, val issuesOpened: Int)
|
||||
|
||||
internal const val ACTIVITY_WINDOW_DAYS = 7
|
||||
|
||||
/**
|
||||
* The repositories with the most recent activity among [items], newest first. Counts only cover
|
||||
* what [items] holds: a very busy week can run past what was loaded, so the bars show the shape of
|
||||
* recent activity rather than an audited total.
|
||||
*/
|
||||
internal fun activeRepositoriesOf(
|
||||
items: List<ActivityItem>,
|
||||
today: LocalDate,
|
||||
zone: ZoneId,
|
||||
limit: Int = 5,
|
||||
): List<ActiveRepository> {
|
||||
val firstDay = today.minusDays((ACTIVITY_WINDOW_DAYS - 1).toLong())
|
||||
return items.groupBy(ActivityItem::repository)
|
||||
.map { (repository, activities) ->
|
||||
val latest = activities.maxBy(ActivityItem::createdAtEpochMillis)
|
||||
val counts = IntArray(ACTIVITY_WINDOW_DAYS)
|
||||
activities.forEach { activity ->
|
||||
val day = activity.localDate(zone)
|
||||
if (!day.isBefore(firstDay) && !day.isAfter(today)) {
|
||||
counts[(day.toEpochDay() - firstDay.toEpochDay()).toInt()]++
|
||||
}
|
||||
}
|
||||
ActiveRepository(
|
||||
repository = repository,
|
||||
latestRefName = activities.filter { it.refName != null }.maxByOrNull(ActivityItem::createdAtEpochMillis)?.refName,
|
||||
latestActor = latest.actorLogin,
|
||||
latestAtEpochMillis = latest.createdAtEpochMillis,
|
||||
dailyCounts = counts.toList(),
|
||||
)
|
||||
}
|
||||
.sortedByDescending(ActiveRepository::latestAtEpochMillis)
|
||||
.take(limit)
|
||||
}
|
||||
|
||||
internal fun todayCountsOf(items: List<ActivityItem>, today: LocalDate, zone: ZoneId): TodayCounts {
|
||||
val todays = items.filter { it.localDate(zone) == today }
|
||||
return TodayCounts(
|
||||
pushes = todays.count { it.kind == ActivityKind.PUSH },
|
||||
issuesOpened = todays.count { it.kind == ActivityKind.ISSUE_OPENED },
|
||||
)
|
||||
}
|
||||
|
||||
private fun ActivityItem.localDate(zone: ZoneId): LocalDate =
|
||||
Instant.ofEpochMilli(createdAtEpochMillis).atZone(zone).toLocalDate()
|
||||
@ -0,0 +1,328 @@
|
||||
package com.ubisam.ubgitea.feature.home
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ubisam.ubgitea.core.auth.android.AuthSession
|
||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||
import com.ubisam.ubgitea.core.network.OkHttpTransport
|
||||
import com.ubisam.ubgitea.core.settings.AppSettingsSession
|
||||
import com.ubisam.ubgitea.data.activity.GiteaActivityApi
|
||||
import com.ubisam.ubgitea.data.auth.AuthApi
|
||||
import com.ubisam.ubgitea.data.auth.AuthenticationGateway
|
||||
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
|
||||
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
|
||||
import com.ubisam.ubgitea.data.workitems.GiteaWorkSearchApi
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityCategory
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityItem
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityRepository
|
||||
import com.ubisam.ubgitea.domain.model.Page
|
||||
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
|
||||
import com.ubisam.ubgitea.domain.organizations.OrganizationsRepository
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkScope
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchRepository
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkStateFilter
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkTypeFilter
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.util.Collections
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class HomeError { OFFLINE, AUTHENTICATION_EXPIRED, PERMISSION_DENIED, SERVER }
|
||||
|
||||
/**
|
||||
* Today's numbers. Each is null while it is still loading or when it could not be loaded: the
|
||||
* timeline is what the screen is for, and a count that failed must not take the timeline down
|
||||
* with it.
|
||||
*/
|
||||
data class HomeSummary(
|
||||
val todayPushes: Int? = null,
|
||||
val todayIssuesOpened: Int? = null,
|
||||
/** True when today's feed ran past what is fetched for counting, so the two counts above are lower bounds. */
|
||||
val todayIsLowerBound: Boolean = false,
|
||||
val openPullRequests: Int? = null,
|
||||
val reviewRequested: Int? = null,
|
||||
val assignedIssues: Int? = null,
|
||||
val isLoading: Boolean = true,
|
||||
) {
|
||||
val attentionCount: Int? =
|
||||
if (reviewRequested == null && assignedIssues == null) null else (reviewRequested ?: 0) + (assignedIssues ?: 0)
|
||||
}
|
||||
|
||||
sealed interface HomeUiState {
|
||||
data object Idle : HomeUiState
|
||||
data object Loading : HomeUiState
|
||||
|
||||
class Content(
|
||||
val accountLogin: String,
|
||||
/** The organization whose feed is shown; null for the signed-in account's own feed. */
|
||||
val organization: String?,
|
||||
organizations: List<OrganizationSummary>,
|
||||
val filter: ActivityCategory?,
|
||||
timeline: List<ActivityItem>,
|
||||
val nextPage: Int?,
|
||||
val isLoadingMore: Boolean,
|
||||
val loadMoreFailed: Boolean,
|
||||
activeRepositories: List<ActiveRepository>,
|
||||
val summary: HomeSummary,
|
||||
val today: LocalDate,
|
||||
) : HomeUiState {
|
||||
val organizations: List<OrganizationSummary> = Collections.unmodifiableList(organizations.toList())
|
||||
val timeline: List<ActivityItem> = Collections.unmodifiableList(timeline.toList())
|
||||
val activeRepositories: List<ActiveRepository> = Collections.unmodifiableList(activeRepositories.toList())
|
||||
val visibleTimeline: List<ActivityItem> =
|
||||
if (filter == null) this.timeline else this.timeline.filter { it.kind.category == filter }
|
||||
|
||||
internal fun copy(
|
||||
filter: ActivityCategory? = this.filter,
|
||||
timeline: List<ActivityItem> = this.timeline,
|
||||
nextPage: Int? = this.nextPage,
|
||||
isLoadingMore: Boolean = this.isLoadingMore,
|
||||
loadMoreFailed: Boolean = this.loadMoreFailed,
|
||||
activeRepositories: List<ActiveRepository> = this.activeRepositories,
|
||||
summary: HomeSummary = this.summary,
|
||||
) = Content(
|
||||
accountLogin, organization, organizations, filter, timeline, nextPage, isLoadingMore, loadMoreFailed,
|
||||
activeRepositories, summary, today,
|
||||
)
|
||||
}
|
||||
|
||||
data class Error(val type: HomeError, val organization: String?) : HomeUiState
|
||||
}
|
||||
|
||||
class HomeViewModel(
|
||||
private val authGateway: AuthenticationGateway,
|
||||
private val tokenProvider: BearerTokenProvider,
|
||||
private val activity: ActivityRepository,
|
||||
private val workSearch: WorkSearchRepository,
|
||||
private val organizations: OrganizationsRepository,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
private val zone: () -> ZoneId = ZoneId::systemDefault,
|
||||
) : ViewModel() {
|
||||
private val mutableState = MutableStateFlow<HomeUiState>(HomeUiState.Idle)
|
||||
val state: StateFlow<HomeUiState> = mutableState.asStateFlow()
|
||||
private var operation: Job? = null
|
||||
private var summaryJob: Job? = null
|
||||
private var moreJob: Job? = null
|
||||
private var accountLogin: String? = null
|
||||
private var knownOrganizations: List<OrganizationSummary>? = null
|
||||
|
||||
/** Loads the feed unless it is already on screen; switching back to the tab must not refetch. */
|
||||
fun open() {
|
||||
if (mutableState.value is HomeUiState.Content || mutableState.value is HomeUiState.Loading) return
|
||||
load(organization = null)
|
||||
}
|
||||
|
||||
fun refresh() = load(currentOrganization())
|
||||
|
||||
fun selectOrganization(organization: String?) {
|
||||
if (organization == currentOrganization() && mutableState.value is HomeUiState.Content) return
|
||||
load(organization)
|
||||
}
|
||||
|
||||
fun selectFilter(filter: ActivityCategory?) {
|
||||
val content = mutableState.value as? HomeUiState.Content ?: return
|
||||
mutableState.value = content.copy(filter = filter)
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val content = mutableState.value as? HomeUiState.Content ?: return
|
||||
val page = content.nextPage ?: return
|
||||
if (content.isLoadingMore) return
|
||||
mutableState.value = content.copy(isLoadingMore = true, loadMoreFailed = false)
|
||||
moreJob = viewModelScope.launch {
|
||||
try {
|
||||
val result = feedPage(content.accountLogin, content.organization, date = null, page = page)
|
||||
update { current ->
|
||||
val merged = (current.timeline + result.items).distinctBy(ActivityItem::id)
|
||||
current.copy(
|
||||
timeline = merged,
|
||||
nextPage = result.nextPage,
|
||||
isLoadingMore = false,
|
||||
activeRepositories = activeRepositoriesOf(merged, current.today, zone()),
|
||||
)
|
||||
}
|
||||
} catch (failure: Throwable) {
|
||||
if (failure is CancellationException) throw failure
|
||||
update { it.copy(isLoadingMore = false, loadMoreFailed = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
cancelAll()
|
||||
accountLogin = null
|
||||
knownOrganizations = null
|
||||
mutableState.value = HomeUiState.Idle
|
||||
}
|
||||
|
||||
private fun load(organization: String?) {
|
||||
cancelAll()
|
||||
mutableState.value = HomeUiState.Loading
|
||||
operation = viewModelScope.launch {
|
||||
try {
|
||||
val login = accountLogin ?: authGateway.getAuthenticatedUser(tokenProvider.accessToken()).login
|
||||
.also { accountLogin = it }
|
||||
val today = Instant.ofEpochMilli(now()).atZone(zone()).toLocalDate()
|
||||
val (first, organizationList) = coroutineScope {
|
||||
val feed = async { feedPage(login, organization, date = null, page = 1) }
|
||||
val orgs = async { loadOrganizations() }
|
||||
feed.await() to orgs.await()
|
||||
}
|
||||
val content = HomeUiState.Content(
|
||||
accountLogin = login,
|
||||
organization = organization,
|
||||
organizations = organizationList,
|
||||
filter = null,
|
||||
timeline = first.items.distinctBy(ActivityItem::id),
|
||||
nextPage = first.nextPage,
|
||||
isLoadingMore = false,
|
||||
loadMoreFailed = false,
|
||||
activeRepositories = activeRepositoriesOf(first.items, today, zone()),
|
||||
summary = HomeSummary(),
|
||||
today = today,
|
||||
)
|
||||
mutableState.value = content
|
||||
loadSummary(login, organization, today)
|
||||
} catch (failure: Throwable) {
|
||||
if (failure is CancellationException) throw failure
|
||||
mutableState.value = HomeUiState.Error(failure.toHomeError(), organization)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every number is fetched on its own and tolerated on its own: one failing count leaves that
|
||||
// one tile blank instead of failing the screen.
|
||||
private fun loadSummary(login: String, organization: String?, today: LocalDate) {
|
||||
summaryJob = viewModelScope.launch {
|
||||
coroutineScope {
|
||||
val todays = async { tolerated { loadToday(login, organization, today) } }
|
||||
val openPullRequests = async {
|
||||
tolerated { workSearch.count(WorkSearchQuery(type = WorkTypeFilter.PULL_REQUESTS, owner = organization)) }
|
||||
}
|
||||
val reviewRequested = async {
|
||||
tolerated {
|
||||
workSearch.count(
|
||||
WorkSearchQuery(WorkScope.REVIEW_REQUESTED, WorkStateFilter.OPEN, WorkTypeFilter.PULL_REQUESTS, organization),
|
||||
)
|
||||
}
|
||||
}
|
||||
val assigned = async {
|
||||
tolerated {
|
||||
workSearch.count(
|
||||
WorkSearchQuery(WorkScope.ASSIGNED_TO_ME, WorkStateFilter.OPEN, WorkTypeFilter.ISSUES, organization),
|
||||
)
|
||||
}
|
||||
}
|
||||
val todayResult = todays.await()
|
||||
val summary = HomeSummary(
|
||||
todayPushes = todayResult?.counts?.pushes,
|
||||
todayIssuesOpened = todayResult?.counts?.issuesOpened,
|
||||
todayIsLowerBound = todayResult?.isLowerBound == true,
|
||||
openPullRequests = openPullRequests.await(),
|
||||
reviewRequested = reviewRequested.await(),
|
||||
assignedIssues = assigned.await(),
|
||||
isLoading = false,
|
||||
)
|
||||
update { it.copy(summary = summary) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Today(val counts: TodayCounts, val isLowerBound: Boolean)
|
||||
|
||||
private suspend fun loadToday(login: String, organization: String?, today: LocalDate): Today {
|
||||
val items = mutableListOf<ActivityItem>()
|
||||
var page: Int? = 1
|
||||
var fetched = 0
|
||||
while (page != null && fetched < TODAY_MAX_PAGES) {
|
||||
val result = feedPage(login, organization, date = today, page = page)
|
||||
items += result.items
|
||||
page = result.nextPage
|
||||
fetched++
|
||||
}
|
||||
return Today(todayCountsOf(items, today, zone()), isLowerBound = page != null)
|
||||
}
|
||||
|
||||
private suspend fun feedPage(login: String, organization: String?, date: LocalDate?, page: Int): Page<ActivityItem> =
|
||||
if (organization == null) {
|
||||
activity.listUserFeed(login, date, page, FEED_PAGE_SIZE)
|
||||
} else {
|
||||
activity.listOrganizationFeed(organization, date, page, FEED_PAGE_SIZE)
|
||||
}
|
||||
|
||||
// The organization filter is a convenience. Failing to list organizations hides the filter;
|
||||
// it does not fail the feed.
|
||||
private suspend fun loadOrganizations(): List<OrganizationSummary> {
|
||||
knownOrganizations?.let { return it }
|
||||
return tolerated { organizations.listOrganizations(1, ORGANIZATION_PAGE_SIZE).items }
|
||||
?.also { knownOrganizations = it }
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
private suspend fun <T> tolerated(block: suspend () -> T): T? = try {
|
||||
block()
|
||||
} catch (failure: Throwable) {
|
||||
if (failure is CancellationException) throw failure
|
||||
null
|
||||
}
|
||||
|
||||
private inline fun update(transform: (HomeUiState.Content) -> HomeUiState.Content) {
|
||||
val current = mutableState.value as? HomeUiState.Content ?: return
|
||||
mutableState.value = transform(current)
|
||||
}
|
||||
|
||||
private fun currentOrganization(): String? = when (val current = mutableState.value) {
|
||||
is HomeUiState.Content -> current.organization
|
||||
is HomeUiState.Error -> current.organization
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun cancelAll() {
|
||||
operation?.cancel()
|
||||
summaryJob?.cancel()
|
||||
moreJob?.cancel()
|
||||
}
|
||||
|
||||
class Factory(private val application: Application) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
require(modelClass.isAssignableFrom(HomeViewModel::class.java))
|
||||
val config = UBGiteaOAuthDefaults.config
|
||||
val tokenProvider = AuthSession.tokenProvider(application)
|
||||
val retry = { AppSettingsSession.store(application).isReactiveAuthRetryEnabled() }
|
||||
return HomeViewModel(
|
||||
authGateway = AuthApi(config, OkHttpTransport()),
|
||||
tokenProvider = tokenProvider,
|
||||
activity = GiteaActivityApi(config, OkHttpTransport(), tokenProvider, reactiveAuthRetryEnabled = retry),
|
||||
workSearch = GiteaWorkSearchApi(config, OkHttpTransport(), tokenProvider, reactiveAuthRetryEnabled = retry),
|
||||
organizations = GiteaRepositoryBrowserApi(config, OkHttpTransport(), tokenProvider, reactiveAuthRetryEnabled = retry),
|
||||
) as T
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FEED_PAGE_SIZE = 50
|
||||
const val ORGANIZATION_PAGE_SIZE = 50
|
||||
const val TODAY_MAX_PAGES = 3
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Throwable.toHomeError(): HomeError = when (this) {
|
||||
is ApiFailure.AuthenticationExpired -> HomeError.AUTHENTICATION_EXPIRED
|
||||
is ApiFailure.PermissionDenied -> HomeError.PERMISSION_DENIED
|
||||
is ApiFailure.Network, is IOException -> HomeError.OFFLINE
|
||||
else -> HomeError.SERVER
|
||||
}
|
||||
@ -1,37 +1,53 @@
|
||||
package com.ubisam.ubgitea.feature.notifications
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectState
|
||||
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectType
|
||||
import com.ubisam.ubgitea.domain.notifications.NotificationSummary
|
||||
import com.ubisam.ubgitea.ui.SimplePaginationFooter
|
||||
import com.ubisam.ubgitea.ui.components.ActivityTone
|
||||
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.KindTile
|
||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||
import com.ubisam.ubgitea.ui.components.TabTitle
|
||||
import com.ubisam.ubgitea.ui.formatRelative
|
||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||
|
||||
@Composable
|
||||
fun NotificationsScreen(
|
||||
@ -44,116 +60,155 @@ fun NotificationsScreen(
|
||||
onRetry: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onAuthenticationExpired: () -> Unit,
|
||||
accountName: String?,
|
||||
instanceHost: String,
|
||||
onViewSettings: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
when (state) {
|
||||
NotificationsUiState.Idle -> Unit
|
||||
NotificationsUiState.Loading -> {
|
||||
FilterTabs(unreadOnly = true, onShowUnreadOnly, onShowAll)
|
||||
CenterMessage { CircularProgressIndicator() }
|
||||
}
|
||||
is NotificationsUiState.Content -> {
|
||||
FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll)
|
||||
LazyColumn(Modifier.weight(1f)) {
|
||||
items(state.items, key = NotificationSummary::id) { notification ->
|
||||
NotificationRow(notification) { onNotificationSelected(notification) }
|
||||
}
|
||||
}
|
||||
PageFooter(state.page, state.page > 1, state.hasNextPage, onPreviousPage, onNextPage)
|
||||
}
|
||||
is NotificationsUiState.Empty -> {
|
||||
FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll)
|
||||
CenterMessage { Text(stringResource(R.string.notifications_empty)) }
|
||||
}
|
||||
is NotificationsUiState.Error -> {
|
||||
FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll)
|
||||
ErrorMessage(state.type, onRetry, onAuthenticationExpired)
|
||||
}
|
||||
Column(modifier.fillMaxSize()) {
|
||||
BrandTopBar(accountName, instanceHost, onViewSettings, onLogout)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(bottom = 12.dp)) {
|
||||
TabTitle(stringResource(R.string.notifications_title))
|
||||
FilterTabs(state.unreadOnlyOrDefault(), onShowUnreadOnly, onShowAll)
|
||||
}
|
||||
}
|
||||
when (state) {
|
||||
NotificationsUiState.Idle, NotificationsUiState.Loading -> CenteredLoading(null)
|
||||
is NotificationsUiState.Content -> NotificationsList(state, onNotificationSelected, onNextPage, onPreviousPage)
|
||||
is NotificationsUiState.Empty -> CenteredMessage(stringResource(R.string.notifications_empty))
|
||||
is NotificationsUiState.Error -> {
|
||||
val expired = state.type == NotificationsError.AUTHENTICATION_EXPIRED
|
||||
CenteredMessage(
|
||||
stringResource(state.type.messageResource),
|
||||
action = stringResource(if (expired) R.string.notifications_sign_in_again else R.string.notifications_retry),
|
||||
onAction = if (expired) onAuthenticationExpired else onRetry,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationsUiState.unreadOnlyOrDefault(): Boolean = when (this) {
|
||||
NotificationsUiState.Idle, NotificationsUiState.Loading -> true
|
||||
is NotificationsUiState.Content -> unreadOnly
|
||||
is NotificationsUiState.Empty -> unreadOnly
|
||||
is NotificationsUiState.Error -> unreadOnly
|
||||
}
|
||||
|
||||
@Composable
|
||||
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)) })
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterChip(unreadOnly, onShowUnreadOnly, { Text(stringResource(R.string.notifications_filter_unread)) })
|
||||
FilterChip(!unreadOnly, onShowAll, { Text(stringResource(R.string.notifications_filter_all)) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationRow(notification: NotificationSummary, onSelected: () -> Unit) {
|
||||
private fun NotificationsList(
|
||||
state: NotificationsUiState.Content,
|
||||
onSelected: (NotificationSummary) -> Unit,
|
||||
onNextPage: () -> Unit,
|
||||
onPreviousPage: () -> Unit,
|
||||
) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||
itemsIndexed(state.items, key = { _, item -> item.id }) { index, notification ->
|
||||
NotificationRow(notification, index == 0, index == state.items.lastIndex) { onSelected(notification) }
|
||||
}
|
||||
item(key = "footer") {
|
||||
SimplePaginationFooter(
|
||||
previousLabel = stringResource(R.string.notifications_previous_page),
|
||||
pageLabel = stringResource(R.string.notifications_page_number, state.page),
|
||||
nextLabel = stringResource(R.string.notifications_next_page),
|
||||
hasPrevious = state.page > 1,
|
||||
hasNext = state.hasNextPage,
|
||||
onPrevious = onPreviousPage,
|
||||
onNext = onNextPage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationRow(notification: NotificationSummary, isFirst: Boolean, isLast: Boolean, onSelected: () -> Unit) {
|
||||
val navigable = notification.subjectNumber != null &&
|
||||
(notification.subjectType == NotificationSubjectType.ISSUE || notification.subjectType == NotificationSubjectType.PULL_REQUEST)
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.let { if (navigable) it.clickable(role = Role.Button, onClick = onSelected) else it }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (notification.isUnread) {
|
||||
Text(stringResource(R.string.notifications_unread_label), style = MaterialTheme.typography.bodySmall)
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (isFirst) 20.dp else 0.dp,
|
||||
topEnd = if (isFirst) 20.dp else 0.dp,
|
||||
bottomStart = if (isLast) 20.dp else 0.dp,
|
||||
bottomEnd = if (isLast) 20.dp else 0.dp,
|
||||
)
|
||||
val tone = notification.subjectState.tone()
|
||||
val reference = notification.subjectNumber?.let {
|
||||
stringResource(R.string.notifications_reference, notification.repositoryName, it)
|
||||
} ?: notification.repositoryName
|
||||
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
|
||||
Column {
|
||||
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.let { if (navigable) it.clickable(role = Role.Button, onClick = onSelected) else it }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (notification.isUnread) {
|
||||
val description = stringResource(R.string.notifications_unread_dot_description)
|
||||
Box(
|
||||
Modifier.padding(top = 6.dp).size(9.dp).clip(CircleShape)
|
||||
.background(UBGiteaTheme.colors.brand)
|
||||
.semantics { contentDescription = description },
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.size(9.dp).padding(top = 6.dp))
|
||||
}
|
||||
KindTile(notification.kindIcon(), tone)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
reference,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
notification.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (notification.isUnread) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
StatusBadge(stringResource(notification.subjectState.labelResource), tone)
|
||||
}
|
||||
Text(
|
||||
formatRelative(notification.updatedAtEpochMillis),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.notifications_repository, notification.repositoryOwner, notification.repositoryName),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(stringResource(notification.subjectType.labelResource), style = MaterialTheme.typography.bodySmall)
|
||||
StatusBadge(notification.subjectState)
|
||||
}
|
||||
Text(notification.title, style = MaterialTheme.typography.titleMedium, maxLines = 2)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorMessage(type: NotificationsError, onRetry: () -> Unit, onAuthenticationExpired: () -> Unit) {
|
||||
CenterMessage {
|
||||
Text(stringResource(type.messageResource))
|
||||
if (type == NotificationsError.AUTHENTICATION_EXPIRED) {
|
||||
Button(onClick = onAuthenticationExpired) { Text(stringResource(R.string.notifications_sign_in_again)) }
|
||||
} else {
|
||||
Button(onClick = onRetry) { Text(stringResource(R.string.notifications_retry)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PageFooter(page: Int, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) {
|
||||
SimplePaginationFooter(
|
||||
previousLabel = stringResource(R.string.notifications_previous_page),
|
||||
pageLabel = stringResource(R.string.notifications_page_number, page),
|
||||
nextLabel = stringResource(R.string.notifications_next_page),
|
||||
hasPrevious = hasPrevious,
|
||||
hasNext = hasNext,
|
||||
onPrevious = onPrevious,
|
||||
onNext = onNext,
|
||||
)
|
||||
private fun NotificationSummary.kindIcon(): Int = when (subjectType) {
|
||||
NotificationSubjectType.ISSUE -> R.drawable.ic_ub_issue
|
||||
NotificationSubjectType.PULL_REQUEST ->
|
||||
if (subjectState == NotificationSubjectState.MERGED) R.drawable.ic_ub_merge else R.drawable.ic_ub_pull_request
|
||||
NotificationSubjectType.COMMIT -> R.drawable.ic_ub_commit
|
||||
NotificationSubjectType.REPOSITORY -> R.drawable.ic_ub_repo
|
||||
NotificationSubjectType.OTHER -> R.drawable.ic_ub_more
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterMessage(content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
private val NotificationSubjectType.labelResource: Int
|
||||
get() = when (this) {
|
||||
NotificationSubjectType.ISSUE -> R.string.notifications_subject_issue
|
||||
NotificationSubjectType.PULL_REQUEST -> R.string.notifications_subject_pull_request
|
||||
NotificationSubjectType.COMMIT -> R.string.notifications_subject_commit
|
||||
NotificationSubjectType.REPOSITORY -> R.string.notifications_subject_repository
|
||||
NotificationSubjectType.OTHER -> R.string.notifications_subject_other
|
||||
}
|
||||
|
||||
private val NotificationSubjectState.labelResource: Int
|
||||
get() = when (this) {
|
||||
NotificationSubjectState.OPEN -> R.string.notifications_state_open
|
||||
@ -162,28 +217,11 @@ private val NotificationSubjectState.labelResource: Int
|
||||
NotificationSubjectState.UNKNOWN -> R.string.notifications_state_unknown
|
||||
}
|
||||
|
||||
// Same compact colored-Surface badge WorkItemsScreen's issue/PR rows use (see that file's own
|
||||
// StatusBadge doc comment for the full rationale) -- kept as its own copy rather than a shared
|
||||
// component since these are two different feature packages and the two state enums
|
||||
// (NotificationSubjectState vs. IssueState/PullRequestState) don't share a type to unify over.
|
||||
// UNKNOWN -- a real Gitea subject state this client doesn't recognize, not a state to visually
|
||||
// emphasize -- gets the same neutral surfaceVariant treatment VisibilityBadge uses for "private"
|
||||
// rather than either affirmative-state color.
|
||||
@Composable
|
||||
private fun StatusBadge(state: NotificationSubjectState) {
|
||||
val (container, content) = when (state) {
|
||||
NotificationSubjectState.OPEN -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
|
||||
NotificationSubjectState.CLOSED -> MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
|
||||
NotificationSubjectState.MERGED -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer
|
||||
NotificationSubjectState.UNKNOWN -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(color = container, contentColor = content, shape = RoundedCornerShape(4.dp)) {
|
||||
Text(
|
||||
stringResource(state.labelResource),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
private fun NotificationSubjectState.tone(): ActivityTone = when (this) {
|
||||
NotificationSubjectState.OPEN -> ActivityTone.SUCCESS
|
||||
NotificationSubjectState.CLOSED -> ActivityTone.DANGER
|
||||
NotificationSubjectState.MERGED -> ActivityTone.MERGED
|
||||
NotificationSubjectState.UNKNOWN -> ActivityTone.NEUTRAL
|
||||
}
|
||||
|
||||
private val NotificationsError.messageResource: Int
|
||||
|
||||
@ -43,6 +43,21 @@ class NotificationsViewModel(private val repository: NotificationsRepository) :
|
||||
val state: StateFlow<NotificationsUiState> = mutableState.asStateFlow()
|
||||
private var operation: Job? = null
|
||||
|
||||
// For the tab's badge. Null until known; a failed refresh keeps the last known count rather
|
||||
// than blanking the badge.
|
||||
private val mutableUnreadCount = MutableStateFlow<Int?>(null)
|
||||
val unreadCount: StateFlow<Int?> = mutableUnreadCount.asStateFlow()
|
||||
|
||||
fun refreshUnreadCount() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.countUnread()?.let { mutableUnreadCount.value = it }
|
||||
} catch (failure: Throwable) {
|
||||
if (failure is CancellationException) throw failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun open() = loadPage(page = 1, unreadOnly = true)
|
||||
|
||||
fun showUnreadOnly() = loadPage(page = 1, unreadOnly = true)
|
||||
@ -76,6 +91,11 @@ class NotificationsViewModel(private val repository: NotificationsRepository) :
|
||||
mutableState.value = NotificationsUiState.Idle
|
||||
}
|
||||
|
||||
/** Forgets the count along with the session it belonged to. */
|
||||
fun clearUnreadCount() {
|
||||
mutableUnreadCount.value = null
|
||||
}
|
||||
|
||||
private fun loadPage(page: Int, unreadOnly: Boolean) {
|
||||
operation?.cancel()
|
||||
mutableState.value = NotificationsUiState.Loading
|
||||
|
||||
@ -1,62 +1,80 @@
|
||||
package com.ubisam.ubgitea.feature.repositories
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
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.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.LineBreak
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
|
||||
import com.ubisam.ubgitea.ui.SimplePaginationFooter
|
||||
import com.ubisam.ubgitea.ui.components.UBGiteaTopBar
|
||||
import com.ubisam.ubgitea.ui.components.ActivityTone
|
||||
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
||||
import com.ubisam.ubgitea.ui.components.CautionBanner
|
||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.MonoChip
|
||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||
import com.ubisam.ubgitea.ui.components.SectionTitle
|
||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||
import com.ubisam.ubgitea.ui.components.TabTitle
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import com.ubisam.ubgitea.ui.formatInstant
|
||||
import com.ubisam.ubgitea.ui.formatRelative
|
||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||
|
||||
@Composable
|
||||
fun RepositoryListScreen(
|
||||
state: RepositoryListUiState,
|
||||
instanceHost: String,
|
||||
onSignIn: () -> Unit,
|
||||
onCancelSignIn: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
@ -74,115 +92,101 @@ fun RepositoryListScreen(
|
||||
onShowFavorites: () -> Unit,
|
||||
onViewWorkItems: (RepositoryRowUiModel) -> Unit,
|
||||
onSelectOrganization: (String?) -> Unit,
|
||||
onViewNotifications: () -> Unit,
|
||||
onViewSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = { UBGiteaTopBar() },
|
||||
) { padding ->
|
||||
Column(modifier.fillMaxSize()) {
|
||||
when (state) {
|
||||
RepositoryListUiState.SignedOut -> MessageState(
|
||||
RepositoryListUiState.SignedOut -> SignedOutState(
|
||||
stringResource(R.string.repositories_sign_in_description),
|
||||
stringResource(R.string.repositories_sign_in),
|
||||
onSignIn,
|
||||
Modifier.padding(padding),
|
||||
)
|
||||
RepositoryListUiState.WaitingForBrowser -> MessageState(
|
||||
RepositoryListUiState.WaitingForBrowser -> SignedOutState(
|
||||
stringResource(R.string.repositories_waiting_browser),
|
||||
stringResource(R.string.repositories_cancel_login),
|
||||
onCancelSignIn,
|
||||
Modifier.padding(padding),
|
||||
)
|
||||
RepositoryListUiState.Loading -> LoadingState(
|
||||
stringResource(R.string.repositories_loading),
|
||||
Modifier.padding(padding),
|
||||
)
|
||||
is RepositoryListUiState.Empty -> Column(
|
||||
Modifier.padding(padding).fillMaxSize()
|
||||
.modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites),
|
||||
) {
|
||||
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
|
||||
RepositoryListUiState.Loading -> {
|
||||
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
||||
CenteredLoading(stringResource(R.string.repositories_loading))
|
||||
}
|
||||
is RepositoryListUiState.Empty -> {
|
||||
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(bottom = 12.dp)) {
|
||||
TabTitle(stringResource(R.string.repositories_list_title))
|
||||
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
|
||||
}
|
||||
}
|
||||
// FAVORITES-mode empty doesn't mean the account has nothing to show -- it means
|
||||
// nothing is starred *yet*, while 전체 저장소 (the other tab, right above this
|
||||
// message) is very likely non-empty. The only sensible primary action here is
|
||||
// "go look at 전체 저장소 and star something", never 로그아웃 -- offering logout as
|
||||
// if this were a dead end was a copy-paste leftover from the ALL-mode branch below,
|
||||
// where the account genuinely has zero repositories and logout is the only thing
|
||||
// left to do.
|
||||
// "go look at 전체 저장소 and star something", never 로그아웃.
|
||||
if (state.mode == RepositoryListMode.FAVORITES) {
|
||||
MessageState(
|
||||
CenteredMessage(
|
||||
stringResource(R.string.repositories_favorites_empty),
|
||||
stringResource(R.string.repositories_favorites_empty_action),
|
||||
onShowAllRepositories,
|
||||
Modifier.weight(1f),
|
||||
action = stringResource(R.string.repositories_favorites_empty_action),
|
||||
onAction = onShowAllRepositories,
|
||||
)
|
||||
} else {
|
||||
MessageState(
|
||||
CenteredMessage(
|
||||
stringResource(R.string.repositories_empty_account, state.accountName),
|
||||
stringResource(R.string.repositories_logout),
|
||||
onLogout,
|
||||
Modifier.weight(1f),
|
||||
action = stringResource(R.string.repositories_logout),
|
||||
onAction = onLogout,
|
||||
)
|
||||
}
|
||||
}
|
||||
is RepositoryListUiState.Error -> {
|
||||
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
||||
val needsLogin = state.type == RepositoryListError.AUTHENTICATION_EXPIRED ||
|
||||
state.type == RepositoryListError.INVALID_CALLBACK
|
||||
val baseMessage = state.accountName?.let {
|
||||
stringResource(R.string.repositories_error_account, it, stringResource(state.type.messageResource))
|
||||
} ?: stringResource(state.type.messageResource)
|
||||
MessageState(
|
||||
CenteredMessage(
|
||||
state.debugDetail?.let { "$baseMessage\n\n[debug] $it" } ?: baseMessage,
|
||||
stringResource(if (needsLogin) R.string.repositories_sign_in else R.string.repositories_retry),
|
||||
if (needsLogin) onSignIn else onRetry,
|
||||
Modifier.padding(padding),
|
||||
action = stringResource(if (needsLogin) R.string.repositories_sign_in else R.string.repositories_retry),
|
||||
onAction = if (needsLogin) onSignIn else onRetry,
|
||||
secondaryAction = if (state.canLogout) stringResource(R.string.repositories_logout) else null,
|
||||
onSecondaryAction = onLogout,
|
||||
)
|
||||
}
|
||||
is RepositoryListUiState.Content -> RepositoryContent(
|
||||
state,
|
||||
onLogout,
|
||||
onRepositorySelected,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
onRefreshRepositories,
|
||||
onRetryLastPushedBranch,
|
||||
onShowAllRepositories,
|
||||
onShowFavorites,
|
||||
onSelectOrganization,
|
||||
onViewNotifications,
|
||||
onViewSettings,
|
||||
onToggleFavorite,
|
||||
Modifier.padding(padding),
|
||||
)
|
||||
is RepositoryListUiState.Content -> {
|
||||
BrandTopBar(state.accountName, instanceHost, onViewSettings, onLogout)
|
||||
RepositoryContent(
|
||||
state,
|
||||
onRepositorySelected,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
onRefreshRepositories,
|
||||
onRetryLastPushedBranch,
|
||||
onShowAllRepositories,
|
||||
onShowFavorites,
|
||||
onSelectOrganization,
|
||||
onToggleFavorite,
|
||||
)
|
||||
}
|
||||
is RepositoryListUiState.ReferencesLoading -> {
|
||||
BackHandler(onBack = onBackToRepositories)
|
||||
LoadingState(
|
||||
stringResource(R.string.repositories_references_loading),
|
||||
Modifier.padding(padding),
|
||||
)
|
||||
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
|
||||
CenteredLoading(stringResource(R.string.repositories_references_loading))
|
||||
}
|
||||
is RepositoryListUiState.ReferencesContent -> {
|
||||
BackHandler(onBack = onBackToRepositories)
|
||||
ReferencesContent(
|
||||
state,
|
||||
onReferenceSelected,
|
||||
onReferencesRefresh,
|
||||
onLogout,
|
||||
onViewWorkItems,
|
||||
Modifier.padding(padding),
|
||||
)
|
||||
ReferencesContent(state, onReferenceSelected, onReferencesRefresh, onViewWorkItems, onBackToRepositories)
|
||||
}
|
||||
is RepositoryListUiState.ReferencesError -> {
|
||||
BackHandler(onBack = onBackToRepositories)
|
||||
MessageState(
|
||||
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
|
||||
CenteredMessage(
|
||||
stringResource(state.type.messageResource),
|
||||
stringResource(R.string.repositories_retry),
|
||||
onRetry,
|
||||
Modifier.padding(padding),
|
||||
action = stringResource(R.string.repositories_retry),
|
||||
onAction = onRetry,
|
||||
secondaryAction = stringResource(R.string.repositories_back),
|
||||
onSecondaryAction = onBackToRepositories,
|
||||
)
|
||||
@ -192,36 +196,25 @@ fun RepositoryListScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingState(message: String, modifier: Modifier = Modifier) {
|
||||
private fun SignedOutState(message: String, action: String, onAction: () -> Unit) {
|
||||
Column(
|
||||
modifier.fillMaxSize(),
|
||||
Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(message, modifier = Modifier.padding(top = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageState(
|
||||
message: String,
|
||||
action: String,
|
||||
onAction: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
secondaryAction: String? = null,
|
||||
onSecondaryAction: () -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(message, style = MaterialTheme.typography.bodyLarge)
|
||||
Image(
|
||||
painter = painterResource(R.drawable.logo_ubisam),
|
||||
contentDescription = stringResource(R.string.shell_logo_description),
|
||||
modifier = Modifier.height(36.dp),
|
||||
)
|
||||
Text(
|
||||
message,
|
||||
// Korean breaks between any two characters by default; this keeps words whole.
|
||||
style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Heading),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 20.dp),
|
||||
)
|
||||
Button(onClick = onAction, modifier = Modifier.padding(top = 20.dp)) { Text(action) }
|
||||
secondaryAction?.let {
|
||||
OutlinedButton(onClick = onSecondaryAction, modifier = Modifier.padding(top = 8.dp)) { Text(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -229,7 +222,6 @@ private fun MessageState(
|
||||
@Composable
|
||||
private fun RepositoryContent(
|
||||
state: RepositoryListUiState.Content,
|
||||
onLogout: () -> Unit,
|
||||
onRepositorySelected: (RepositoryRowUiModel) -> Unit,
|
||||
onNextPage: () -> Unit,
|
||||
onPreviousPage: () -> Unit,
|
||||
@ -238,137 +230,58 @@ private fun RepositoryContent(
|
||||
onShowAllRepositories: () -> Unit,
|
||||
onShowFavorites: () -> Unit,
|
||||
onSelectOrganization: (String?) -> Unit,
|
||||
onViewNotifications: () -> Unit,
|
||||
onViewSettings: () -> Unit,
|
||||
onToggleFavorite: (RepositoryRowUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = false,
|
||||
onRefresh = onRefreshRepositories,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites),
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(stringResource(R.string.repositories_title), style = MaterialTheme.typography.headlineSmall)
|
||||
Text(stringResource(R.string.repositories_account, state.accountName))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
OverflowMenuButton(onViewSettings, onViewNotifications, onLogout)
|
||||
Column(Modifier.padding(bottom = 12.dp)) {
|
||||
TabTitle(stringResource(R.string.repositories_list_title))
|
||||
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
|
||||
if (state.mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()) {
|
||||
OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization)
|
||||
}
|
||||
}
|
||||
state.cachedAtEpochMillis?.let { cachedAt -> item { OfflineCacheBanner(cachedAt) } }
|
||||
item { ModeTabs(state.mode, onShowAllRepositories, onShowFavorites) }
|
||||
if (state.mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()) {
|
||||
item { OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization) }
|
||||
}
|
||||
items(state.repositories, key = { repositoryLazyListKey(it.coordinates) }) { repository ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(role = Role.Button) { onRepositorySelected(repository) }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text("${repository.owner}/${repository.name}", style = MaterialTheme.typography.titleMedium)
|
||||
VisibilityBadge(repository.isPrivate, Modifier.padding(top = 4.dp, bottom = 2.dp))
|
||||
repository.description?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
|
||||
Text(
|
||||
repository.defaultBranch?.let {
|
||||
stringResource(R.string.repositories_default_branch, it)
|
||||
} ?: 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)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (repository.language != null) {
|
||||
Text(repository.language, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
Spacer(Modifier)
|
||||
}
|
||||
FavoriteStar(state.mode, repository.favoriteAction, repository.starsCount) { onToggleFavorite(repository) }
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
SimplePaginationFooter(
|
||||
previousLabel = stringResource(R.string.repositories_previous_page),
|
||||
pageLabel = stringResource(R.string.repositories_page_number, state.page),
|
||||
nextLabel = stringResource(R.string.repositories_next_page),
|
||||
hasPrevious = state.page > 1,
|
||||
hasNext = state.hasNextPage,
|
||||
onPrevious = onPreviousPage,
|
||||
onNext = onNextPage,
|
||||
)
|
||||
}
|
||||
}
|
||||
state.cachedAtEpochMillis?.let { cachedAt ->
|
||||
CautionBanner(stringResource(R.string.repositories_offline_cached_banner, formatInstant(cachedAt)))
|
||||
}
|
||||
PullToRefreshBox(isRefreshing = false, onRefresh = onRefreshRepositories, modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites),
|
||||
contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp),
|
||||
) {
|
||||
itemsIndexed(state.repositories, key = { _, repository -> repositoryLazyListKey(repository.coordinates) }) { index, repository ->
|
||||
RepositoryRow(
|
||||
repository,
|
||||
isFirst = index == 0,
|
||||
isLast = index == state.repositories.lastIndex,
|
||||
mode = state.mode,
|
||||
onSelected = { onRepositorySelected(repository) },
|
||||
onRetryLastPushedBranch = onRetryLastPushedBranch,
|
||||
onToggleFavorite = { onToggleFavorite(repository) },
|
||||
)
|
||||
}
|
||||
item(key = "footer") {
|
||||
SimplePaginationFooter(
|
||||
previousLabel = stringResource(R.string.repositories_previous_page),
|
||||
pageLabel = stringResource(R.string.repositories_page_number, state.page),
|
||||
nextLabel = stringResource(R.string.repositories_next_page),
|
||||
hasPrevious = state.page > 1,
|
||||
hasNext = state.hasNextPage,
|
||||
onPrevious = onPreviousPage,
|
||||
onNext = onNextPage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun repositoryLazyListKey(coordinates: RepositoryCoordinates): String =
|
||||
"${coordinates.owner}/${coordinates.name}"
|
||||
|
||||
// Shown only when this Content came from the on-disk offline cache (RepositoryListViewModel sets
|
||||
// cachedAtEpochMillis exactly then, never on a live response) -- a full-width banner rather than a
|
||||
// small badge, since "you might be looking at stale data" is a stronger, page-level statement than
|
||||
// one repository's visibility. Uses the theme's error-adjacent tertiary-container tone (not the
|
||||
// affirmative primary green VisibilityBadge/StatusBadge use elsewhere) to read as a caution, not a
|
||||
// normal state, per product.md's "never obscure which Gitea instance and account are active."
|
||||
@Composable
|
||||
private fun OfflineCacheBanner(cachedAtEpochMillis: Long) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.repositories_offline_cached_banner, formatInstant(cachedAtEpochMillis)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A compact colored badge instead of a full-width plain-text line (which previously had no
|
||||
// explicit style at all, so it rendered at LocalTextStyle's default size -- larger and more
|
||||
// visually prominent than the metadata lines around it, an unintentional inconsistency). Private
|
||||
// gets a neutral surface tint (matches Gitea web's own muted treatment for private repos); public
|
||||
// gets the theme's primary-container green, since a publicly-visible repository is the more
|
||||
// "affirmative" state worth a touch of accent color.
|
||||
@Composable
|
||||
private fun VisibilityBadge(isPrivate: Boolean, modifier: Modifier = Modifier) {
|
||||
val label = stringResource(if (isPrivate) R.string.repositories_private else R.string.repositories_public)
|
||||
val containerColor = if (isPrivate) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primaryContainer
|
||||
val contentColor = if (isPrivate) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onPrimaryContainer
|
||||
Surface(
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
@ -387,7 +300,7 @@ private fun Modifier.modeSwipeGesture(
|
||||
// Content follows the finger, same convention as HorizontalPager elsewhere in this
|
||||
// app: dragging left (negative totalDragX) brings the next tab in from the right
|
||||
// (ALL -> FAVORITES, since FAVORITES is the right-hand tab), dragging right brings the
|
||||
// previous tab in from the left (FAVORITES -> ALL). This was previously inverted.
|
||||
// previous tab in from the left (FAVORITES -> ALL).
|
||||
when {
|
||||
totalDragX < -SwipeThresholdPx && mode == RepositoryListMode.ALL -> onShowFavorites()
|
||||
totalDragX > SwipeThresholdPx && mode == RepositoryListMode.FAVORITES -> onShowAllRepositories()
|
||||
@ -405,7 +318,7 @@ private fun OrganizationFilterRow(
|
||||
onSelect: (String?) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterChip(selected == null, { onSelect(null) }, { Text(stringResource(R.string.repositories_organization_all)) })
|
||||
@ -419,35 +332,6 @@ private fun OrganizationFilterRow(
|
||||
}
|
||||
}
|
||||
|
||||
// A single overflow ("hamburger") menu replaces what used to be three separate always-visible
|
||||
// buttons (설정/알림/로그아웃) on the repository list's top bar — those three buttons crowded the
|
||||
// header next to the app title. This app has no icon library (see CLAUDE.md's established
|
||||
// convention), so the trigger is the plain Unicode "☰" glyph, matching how ★/☆ are already used
|
||||
// elsewhere for favorites rather than a drawable icon.
|
||||
@Composable
|
||||
private fun OverflowMenuButton(onViewSettings: () -> Unit, onViewNotifications: () -> Unit, onLogout: () -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Text("☰", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.repositories_settings)) },
|
||||
onClick = { expanded = false; onViewSettings() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.repositories_notifications)) },
|
||||
onClick = { expanded = false; onViewNotifications() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.repositories_logout)) },
|
||||
onClick = { expanded = false; onLogout() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeTabs(mode: RepositoryListMode, onShowAllRepositories: () -> Unit, onShowFavorites: () -> Unit) {
|
||||
TabRow(selectedTabIndex = if (mode == RepositoryListMode.FAVORITES) 1 else 0) {
|
||||
@ -464,33 +348,101 @@ private fun ModeTabs(mode: RepositoryListMode, onShowAllRepositories: () -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun LastPushedBranchRow(
|
||||
private fun RepositoryRow(
|
||||
repository: RepositoryRowUiModel,
|
||||
onRetry: (RepositoryCoordinates) -> Unit,
|
||||
isFirst: Boolean,
|
||||
isLast: Boolean,
|
||||
mode: RepositoryListMode,
|
||||
onSelected: () -> Unit,
|
||||
onRetryLastPushedBranch: (RepositoryCoordinates) -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
) {
|
||||
when (val branch = repository.lastPushedBranch) {
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (isFirst) 20.dp else 0.dp,
|
||||
topEnd = if (isFirst) 20.dp else 0.dp,
|
||||
bottomStart = if (isLast) 20.dp else 0.dp,
|
||||
bottomEnd = if (isLast) 20.dp else 0.dp,
|
||||
)
|
||||
val ownerColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
|
||||
Column {
|
||||
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable(role = Role.Button, onClick = onSelected)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = ownerColor)) { append(repository.owner); append(" / ") }
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(repository.name) }
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
StatusBadge(
|
||||
stringResource(if (repository.isPrivate) R.string.repositories_private else R.string.repositories_public),
|
||||
if (repository.isPrivate) ActivityTone.NEUTRAL else ActivityTone.BRAND,
|
||||
)
|
||||
}
|
||||
repository.description?.takeIf(String::isNotBlank)?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (repository.language != null) {
|
||||
Text(repository.language, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
LastPushedBranchChip(repository.lastPushedBranch) { onRetryLastPushedBranch(repository.coordinates) }
|
||||
Text(
|
||||
formatRelative(repository.updatedAtEpochMillis),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
FavoriteStar(mode, repository.favoriteAction, repository.starsCount, onToggleFavorite)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LastPushedBranchChip(state: LastPushedBranchUiState, onRetry: () -> Unit) {
|
||||
when (state) {
|
||||
LastPushedBranchUiState.Loading -> Text(
|
||||
stringResource(R.string.repositories_last_pushed_branch_loading),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is LastPushedBranchUiState.Loaded -> Text(
|
||||
stringResource(R.string.repositories_last_pushed_branch, branch.name),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
is LastPushedBranchUiState.Loaded -> MonoChip(state.name, icon = R.drawable.ic_ub_branch)
|
||||
LastPushedBranchUiState.Empty -> Text(
|
||||
stringResource(R.string.repositories_last_pushed_branch_none),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
LastPushedBranchUiState.Failed -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LastPushedBranchUiState.Failed -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.repositories_last_pushed_branch_failed),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { onRetry(repository.coordinates) },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
) { Text(stringResource(R.string.repositories_retry)) }
|
||||
onClick = onRetry,
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
) { Text(stringResource(R.string.repositories_retry), style = MaterialTheme.typography.labelSmall) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -498,10 +450,9 @@ private fun LastPushedBranchRow(
|
||||
// Reuses the same toggleFavorite action for both directions: on the ALL list it stars (favoriteAction
|
||||
// tracks Idle/InProgress/Starred/Failed for *this session's* interaction only — a repository already
|
||||
// starred before this session still shows as Idle/outlined here until acted on, a known, accepted
|
||||
// limitation carried over unchanged from how favoriteAction already worked); on the FAVORITES list,
|
||||
// every row is inherently already starred (that's why it's showing) and the action un-favorites it —
|
||||
// removing the row on success rather than toggling this icon's own appearance. Star count itself is
|
||||
// always the server's real count, read-only, shown either way.
|
||||
// limitation); on the FAVORITES list, every row is inherently already starred (that's why it's
|
||||
// showing) and the action un-favorites it. Star count itself is always the server's real count,
|
||||
// read-only, shown either way.
|
||||
@Composable
|
||||
private fun FavoriteStar(mode: RepositoryListMode, action: FavoriteActionUiState, starsCount: Int, onClick: () -> Unit) {
|
||||
val inProgress = action == FavoriteActionUiState.InProgress
|
||||
@ -509,15 +460,23 @@ private fun FavoriteStar(mode: RepositoryListMode, action: FavoriteActionUiState
|
||||
val filled = if (mode == RepositoryListMode.FAVORITES) !inProgress else action == FavoriteActionUiState.Starred
|
||||
val color = when {
|
||||
failed -> MaterialTheme.colorScheme.error
|
||||
filled -> MaterialTheme.colorScheme.primary
|
||||
filled -> UBGiteaTheme.colors.brand
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(
|
||||
Modifier.clickable(role = Role.Button, enabled = !inProgress) { onClick() }.padding(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
val isUnfavoriteAction = mode == RepositoryListMode.FAVORITES || filled
|
||||
val description = stringResource(if (isUnfavoriteAction) R.string.repositories_unfavorite else R.string.repositories_favorite)
|
||||
Column(
|
||||
Modifier
|
||||
.heightIn(min = 48.dp)
|
||||
.widthIn(min = 48.dp)
|
||||
.clickable(role = Role.Button, enabled = !inProgress, onClick = onClick)
|
||||
.semantics { contentDescription = description }
|
||||
.padding(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(if (filled) "★" else "☆", color = color, style = MaterialTheme.typography.titleMedium)
|
||||
Text(starsCount.toString(), color = color, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(start = 4.dp))
|
||||
UBIcon(if (filled) R.drawable.ic_ub_star_filled else R.drawable.ic_ub_star, null, tint = color, size = 20.dp)
|
||||
Text(starsCount.toString(), color = color, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
@ -527,92 +486,105 @@ private fun ReferencesContent(
|
||||
state: RepositoryListUiState.ReferencesContent,
|
||||
onReferenceSelected: (RepositoryRowUiModel, String, ReferenceKind) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onViewWorkItems: (RepositoryRowUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackToRepositories: () -> Unit,
|
||||
) {
|
||||
PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = modifier.fillMaxSize()) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
item {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"${state.repository.owner}/${state.repository.name}",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(stringResource(R.string.repositories_account, state.accountName))
|
||||
}
|
||||
OutlinedButton(onClick = onLogout) { Text(stringResource(R.string.repositories_logout)) }
|
||||
ScreenTopBar(
|
||||
title = state.repository.name,
|
||||
onBack = onBackToRepositories,
|
||||
subtitle = state.repository.owner,
|
||||
actions = {
|
||||
IconButton(onClick = { onViewWorkItems(state.repository) }) {
|
||||
UBIcon(R.drawable.ic_ub_pull_request, stringResource(R.string.repositories_work_items))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = { onViewWorkItems(state.repository) }) {
|
||||
Text(stringResource(R.string.repositories_work_items))
|
||||
},
|
||||
)
|
||||
PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||
item(key = "branches-title") { SectionTitle(stringResource(R.string.repositories_branches_title)) }
|
||||
if (state.branches.isEmpty()) {
|
||||
item(key = "branches-empty") { EmptyReferenceMessage(stringResource(R.string.repositories_branches_empty)) }
|
||||
} else {
|
||||
itemsIndexed(state.branches, key = { _, reference -> "branch:${reference.name}" }) { index, reference ->
|
||||
ReferenceRow(reference, index == 0, index == state.branches.lastIndex) { name, kind ->
|
||||
onReferenceSelected(state.repository, name, kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "tags-title") { SectionTitle(stringResource(R.string.repositories_tags_title), Modifier.padding(top = 8.dp)) }
|
||||
if (state.tags.isEmpty()) {
|
||||
item(key = "tags-empty") { EmptyReferenceMessage(stringResource(R.string.repositories_tags_empty)) }
|
||||
} else {
|
||||
itemsIndexed(state.tags, key = { _, reference -> "tag:${reference.name}" }) { index, reference ->
|
||||
ReferenceRow(reference, index == 0, index == state.tags.lastIndex) { name, kind ->
|
||||
onReferenceSelected(state.repository, name, kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.repositories_branches_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
if (state.branches.isEmpty()) {
|
||||
item { Text(stringResource(R.string.repositories_branches_empty), Modifier.padding(horizontal = 16.dp)) }
|
||||
}
|
||||
items(state.branches, key = { "branch:${it.name}" }) { reference ->
|
||||
ReferenceRow(reference) { name, kind -> onReferenceSelected(state.repository, name, kind) }
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.repositories_tags_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
if (state.tags.isEmpty()) {
|
||||
item { Text(stringResource(R.string.repositories_tags_empty), Modifier.padding(horizontal = 16.dp)) }
|
||||
}
|
||||
items(state.tags, key = { "tag:${it.name}" }) { reference ->
|
||||
ReferenceRow(reference) { name, kind -> onReferenceSelected(state.repository, name, kind) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReferenceRow(reference: ReferenceRowUiModel, onSelected: (String, ReferenceKind) -> Unit) {
|
||||
val contentColor = if (reference.isSelected) MaterialTheme.colorScheme.primary else Color.Unspecified
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (reference.isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)
|
||||
.selectable(
|
||||
selected = reference.isSelected,
|
||||
role = Role.RadioButton,
|
||||
onClick = { onSelected(reference.name, reference.kind) },
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(reference.name, style = MaterialTheme.typography.titleMedium, color = contentColor)
|
||||
Text(reference.commitId.take(12), style = MaterialTheme.typography.bodySmall, color = contentColor)
|
||||
reference.commitAtEpochMillis?.let {
|
||||
Text(
|
||||
stringResource(R.string.repositories_commit_at, formatInstant(it)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = contentColor,
|
||||
)
|
||||
private fun EmptyReferenceMessage(message: String) {
|
||||
Text(
|
||||
message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReferenceRow(reference: ReferenceRowUiModel, isFirst: Boolean, isLast: Boolean, onSelected: (String, ReferenceKind) -> Unit) {
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (isFirst) 20.dp else 0.dp,
|
||||
topEnd = if (isFirst) 20.dp else 0.dp,
|
||||
bottomStart = if (isLast) 20.dp else 0.dp,
|
||||
bottomEnd = if (isLast) 20.dp else 0.dp,
|
||||
)
|
||||
val containerColor = if (reference.isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface
|
||||
val contentColor = if (reference.isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
|
||||
val mutedColor = if (reference.isSelected) contentColor else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Surface(color = containerColor, shape = shape, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
|
||||
Column {
|
||||
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.selectable(selected = reference.isSelected, role = Role.RadioButton) { onSelected(reference.name, reference.kind) }
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
UBIcon(
|
||||
if (reference.kind == ReferenceKind.BRANCH) R.drawable.ic_ub_branch else R.drawable.ic_ub_tag,
|
||||
null,
|
||||
tint = mutedColor,
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
reference.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (reference.isProtected) StatusBadge(stringResource(R.string.repositories_protected_branch), ActivityTone.WARNING)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MonoChip(reference.commitId.take(12))
|
||||
reference.commitAtEpochMillis?.let {
|
||||
Text(formatRelative(it), style = MaterialTheme.typography.bodySmall, color = mutedColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reference.isProtected) Text(stringResource(R.string.repositories_protected_branch))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
private val RepositoryListError.messageResource: Int
|
||||
|
||||
@ -100,6 +100,17 @@ sealed interface RepositoryListUiState {
|
||||
) : RepositoryListUiState
|
||||
}
|
||||
|
||||
/** The signed-in account's name, from whichever state carries one. */
|
||||
fun RepositoryListUiState.accountNameOrNull(): String? = when (this) {
|
||||
is RepositoryListUiState.Content -> accountName
|
||||
is RepositoryListUiState.Empty -> accountName
|
||||
is RepositoryListUiState.Error -> accountName
|
||||
is RepositoryListUiState.ReferencesLoading -> accountName
|
||||
is RepositoryListUiState.ReferencesContent -> accountName
|
||||
is RepositoryListUiState.ReferencesError -> accountName
|
||||
RepositoryListUiState.SignedOut, RepositoryListUiState.WaitingForBrowser, RepositoryListUiState.Loading -> null
|
||||
}
|
||||
|
||||
sealed interface LastPushedBranchUiState {
|
||||
data object Loading : LastPushedBranchUiState
|
||||
data class Loaded(val name: String) : LastPushedBranchUiState
|
||||
|
||||
@ -1,62 +1,141 @@
|
||||
package com.ubisam.ubgitea.feature.settings
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
||||
import com.ubisam.ubgitea.ui.components.Avatar
|
||||
import com.ubisam.ubgitea.ui.components.ListCard
|
||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||
import com.ubisam.ubgitea.ui.components.SectionTitle
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
state: SettingsUiState,
|
||||
onSetReactiveAuthRetryEnabled: (Boolean) -> Unit,
|
||||
onSetFavoritePushNotificationsEnabled: (Boolean) -> Unit,
|
||||
accountName: String?,
|
||||
instanceHost: String,
|
||||
onSetCodeWordWrapEnabled: (Boolean) -> Unit,
|
||||
onSetCodeFontSizeSp: (Int) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
|
||||
Text(stringResource(R.string.settings_title), style = MaterialTheme.typography.headlineSmall)
|
||||
SettingRow(
|
||||
title = stringResource(R.string.settings_reactive_auth_retry_title),
|
||||
description = stringResource(R.string.settings_reactive_auth_retry_description),
|
||||
checked = state.reactiveAuthRetryEnabled,
|
||||
onCheckedChange = onSetReactiveAuthRetryEnabled,
|
||||
)
|
||||
HorizontalDivider()
|
||||
SettingRow(
|
||||
title = stringResource(R.string.settings_favorite_push_notifications_title),
|
||||
description = stringResource(R.string.settings_favorite_push_notifications_description),
|
||||
checked = state.favoritePushNotificationsEnabled,
|
||||
onCheckedChange = onSetFavoritePushNotificationsEnabled,
|
||||
)
|
||||
HorizontalDivider()
|
||||
Column(modifier.fillMaxSize()) {
|
||||
ScreenTopBar(title = stringResource(R.string.settings_title), onBack = onBack)
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SectionTitle(stringResource(R.string.settings_section_account))
|
||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Avatar(accountName.orEmpty(), size = 40.dp)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
accountName ?: stringResource(R.string.settings_account_unknown),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
instanceHost,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = onLogout, modifier = Modifier.heightIn(min = 48.dp)) {
|
||||
UBIcon(R.drawable.ic_ub_logout, null, size = 18.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.repositories_logout))
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTitle(stringResource(R.string.settings_section_code_view), Modifier.padding(top = 8.dp))
|
||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||
SwitchRow(
|
||||
title = stringResource(R.string.settings_code_word_wrap_title),
|
||||
description = stringResource(R.string.settings_code_word_wrap_description),
|
||||
checked = state.codeWordWrapEnabled,
|
||||
onCheckedChange = onSetCodeWordWrapEnabled,
|
||||
)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.settings_code_font_size_title), style = MaterialTheme.typography.titleMedium)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
AppSettingsStore.CODE_FONT_SIZES_SP.forEach { size ->
|
||||
FilterChip(
|
||||
state.codeFontSizeSp == size,
|
||||
{ onSetCodeFontSizeSp(size) },
|
||||
{ Text(stringResource(R.string.settings_code_font_size_value, size)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTitle(stringResource(R.string.settings_section_actions), Modifier.padding(top = 8.dp))
|
||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||
SwitchRow(
|
||||
title = stringResource(R.string.settings_reactive_auth_retry_title),
|
||||
description = stringResource(R.string.settings_reactive_auth_retry_description),
|
||||
checked = state.reactiveAuthRetryEnabled,
|
||||
onCheckedChange = onSetReactiveAuthRetryEnabled,
|
||||
)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
SwitchRow(
|
||||
title = stringResource(R.string.settings_favorite_push_notifications_title),
|
||||
description = stringResource(R.string.settings_favorite_push_notifications_description),
|
||||
checked = state.favoritePushNotificationsEnabled,
|
||||
onCheckedChange = onSetFavoritePushNotificationsEnabled,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingRow(
|
||||
private fun SwitchRow(
|
||||
title: String,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 16.dp),
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
|
||||
@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
data class SettingsUiState(
|
||||
val reactiveAuthRetryEnabled: Boolean,
|
||||
val favoritePushNotificationsEnabled: Boolean,
|
||||
val codeWordWrapEnabled: Boolean = true,
|
||||
val codeFontSizeSp: Int = AppSettingsStore.DEFAULT_CODE_FONT_SIZE_SP,
|
||||
)
|
||||
|
||||
/**
|
||||
@ -24,6 +26,8 @@ class SettingsViewModel(private val store: AppSettingsStore) : ViewModel() {
|
||||
SettingsUiState(
|
||||
reactiveAuthRetryEnabled = store.isReactiveAuthRetryEnabled(),
|
||||
favoritePushNotificationsEnabled = store.isFavoritePushNotificationsEnabled(),
|
||||
codeWordWrapEnabled = store.isCodeWordWrapEnabled(),
|
||||
codeFontSizeSp = store.codeFontSizeSp(),
|
||||
),
|
||||
)
|
||||
val state: StateFlow<SettingsUiState> = mutableState.asStateFlow()
|
||||
@ -38,6 +42,17 @@ class SettingsViewModel(private val store: AppSettingsStore) : ViewModel() {
|
||||
mutableState.value = mutableState.value.copy(favoritePushNotificationsEnabled = enabled)
|
||||
}
|
||||
|
||||
fun setCodeWordWrapEnabled(enabled: Boolean) {
|
||||
store.setCodeWordWrapEnabled(enabled)
|
||||
mutableState.value = mutableState.value.copy(codeWordWrapEnabled = enabled)
|
||||
}
|
||||
|
||||
fun setCodeFontSizeSp(size: Int) {
|
||||
if (size !in AppSettingsStore.CODE_FONT_SIZES_SP) return
|
||||
store.setCodeFontSizeSp(size)
|
||||
mutableState.value = mutableState.value.copy(codeFontSizeSp = size)
|
||||
}
|
||||
|
||||
class Factory(private val application: Application) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
|
||||
279
app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt
Normal file
279
app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt
Normal file
@ -0,0 +1,279 @@
|
||||
package com.ubisam.ubgitea.feature.work
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkItemAuthor
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkItemHit
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkScope
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkStateFilter
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkTypeFilter
|
||||
import com.ubisam.ubgitea.ui.SimplePaginationFooter
|
||||
import com.ubisam.ubgitea.ui.components.ActivityTone
|
||||
import com.ubisam.ubgitea.ui.components.BrandTopBar
|
||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.KindTile
|
||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||
import com.ubisam.ubgitea.ui.components.TabTitle
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import com.ubisam.ubgitea.ui.formatRelative
|
||||
|
||||
@Composable
|
||||
fun WorkScreen(
|
||||
state: WorkUiState,
|
||||
accountName: String?,
|
||||
instanceHost: String,
|
||||
onSelectScope: (WorkScope) -> Unit,
|
||||
onSelectState: (WorkStateFilter) -> Unit,
|
||||
onSelectType: (WorkTypeFilter) -> Unit,
|
||||
onItemSelected: (WorkItemHit) -> Unit,
|
||||
onNextPage: () -> Unit,
|
||||
onPreviousPage: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onViewSettings: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onAuthenticationExpired: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier.fillMaxSize()) {
|
||||
BrandTopBar(accountName, instanceHost, onViewSettings, onLogout)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(bottom = 12.dp)) {
|
||||
TabTitle(
|
||||
stringResource(R.string.work_title),
|
||||
subtitle = (state as? WorkUiState.Content)?.totalCount
|
||||
?.let { stringResource(R.string.work_total, it) }
|
||||
?: stringResource(R.string.work_subtitle),
|
||||
)
|
||||
QueryFilters(state.query, onSelectScope, onSelectState, onSelectType)
|
||||
}
|
||||
}
|
||||
when (state) {
|
||||
is WorkUiState.Idle, is WorkUiState.Loading -> CenteredLoading(stringResource(R.string.work_loading))
|
||||
is WorkUiState.Empty -> CenteredMessage(stringResource(R.string.work_empty))
|
||||
is WorkUiState.Error -> {
|
||||
val expired = state.type == WorkError.AUTHENTICATION_EXPIRED
|
||||
CenteredMessage(
|
||||
stringResource(state.type.messageResource),
|
||||
action = stringResource(if (expired) R.string.work_sign_in_again else R.string.work_retry),
|
||||
onAction = if (expired) onAuthenticationExpired else onRefresh,
|
||||
)
|
||||
}
|
||||
is WorkUiState.Content -> WorkList(state, onItemSelected, onNextPage, onPreviousPage, onRefresh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueryFilters(
|
||||
query: WorkSearchQuery,
|
||||
onSelectScope: (WorkScope) -> Unit,
|
||||
onSelectState: (WorkStateFilter) -> Unit,
|
||||
onSelectType: (WorkTypeFilter) -> Unit,
|
||||
) {
|
||||
val scopeLabel = stringResource(R.string.work_scope_label)
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.semantics { contentDescription = scopeLabel }
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
WorkScope.entries.forEach { scope ->
|
||||
FilterChip(query.scope == scope, { onSelectScope(scope) }, { Text(stringResource(scope.label)) })
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
WorkStateFilter.entries.forEach { filter ->
|
||||
FilterChip(query.state == filter, { onSelectState(filter) }, { Text(stringResource(filter.label)) })
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 2.dp))
|
||||
WorkTypeFilter.entries.forEach { filter ->
|
||||
FilterChip(query.type == filter, { onSelectType(filter) }, { Text(stringResource(filter.label)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun WorkList(
|
||||
state: WorkUiState.Content,
|
||||
onItemSelected: (WorkItemHit) -> Unit,
|
||||
onNextPage: () -> Unit,
|
||||
onPreviousPage: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||
items(
|
||||
count = state.items.size,
|
||||
key = { index -> state.items[index].let { "${it.repository.owner}/${it.repository.name}#${it.number}" } },
|
||||
) { index ->
|
||||
WorkRow(state.items[index], isFirst = index == 0, isLast = index == state.items.lastIndex, onItemSelected)
|
||||
}
|
||||
item(key = "footer") {
|
||||
SimplePaginationFooter(
|
||||
previousLabel = stringResource(R.string.work_previous_page),
|
||||
pageLabel = stringResource(R.string.work_page_number, state.page),
|
||||
nextLabel = stringResource(R.string.work_next_page),
|
||||
hasPrevious = state.page > 1,
|
||||
hasNext = state.hasNextPage,
|
||||
onPrevious = onPreviousPage,
|
||||
onNext = onNextPage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorkRow(hit: WorkItemHit, isFirst: Boolean, isLast: Boolean, onSelected: (WorkItemHit) -> Unit) {
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (isFirst) 20.dp else 0.dp,
|
||||
topEnd = if (isFirst) 20.dp else 0.dp,
|
||||
bottomStart = if (isLast) 20.dp else 0.dp,
|
||||
bottomEnd = if (isLast) 20.dp else 0.dp,
|
||||
)
|
||||
val status = hit.status()
|
||||
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
|
||||
Column {
|
||||
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable(role = Role.Button) { onSelected(hit) }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
KindTile(status.icon, status.tone)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatusBadge(stringResource(status.label), status.tone)
|
||||
Text(
|
||||
stringResource(R.string.work_reference, hit.repository.name, hit.number),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
hit.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(R.string.work_meta, hit.author.label(), formatRelative(hit.updatedAtEpochMillis)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
val comments = stringResource(R.string.work_comments_description, hit.commentCount)
|
||||
Row(
|
||||
Modifier.semantics(mergeDescendants = true) { contentDescription = comments },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
UBIcon(R.drawable.ic_ub_comment, null, size = 14.dp, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
hit.commentCount.toString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class WorkStatus(val icon: Int, val tone: ActivityTone, @param:StringRes val label: Int)
|
||||
|
||||
private fun WorkItemHit.status(): WorkStatus = when {
|
||||
isPullRequest && isMerged -> WorkStatus(R.drawable.ic_ub_merge, ActivityTone.MERGED, R.string.work_badge_pull_request_merged)
|
||||
isPullRequest && isOpen -> WorkStatus(R.drawable.ic_ub_pull_request, ActivityTone.SUCCESS, R.string.work_badge_pull_request_open)
|
||||
isPullRequest -> WorkStatus(R.drawable.ic_ub_pull_request, ActivityTone.DANGER, R.string.work_badge_pull_request_closed)
|
||||
isOpen -> WorkStatus(R.drawable.ic_ub_issue, ActivityTone.SUCCESS, R.string.work_badge_issue_open)
|
||||
else -> WorkStatus(R.drawable.ic_ub_issue, ActivityTone.DANGER, R.string.work_badge_issue_closed)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorkItemAuthor.label(): String = when (this) {
|
||||
is WorkItemAuthor.Known -> login
|
||||
WorkItemAuthor.Unavailable -> stringResource(R.string.workitems_author_unavailable)
|
||||
}
|
||||
|
||||
private val WorkScope.label: Int
|
||||
@StringRes get() = when (this) {
|
||||
WorkScope.EVERYONE -> R.string.work_scope_everyone
|
||||
WorkScope.REVIEW_REQUESTED -> R.string.work_scope_review_requested
|
||||
WorkScope.ASSIGNED_TO_ME -> R.string.work_scope_assigned
|
||||
WorkScope.CREATED_BY_ME -> R.string.work_scope_created
|
||||
}
|
||||
|
||||
private val WorkStateFilter.label: Int
|
||||
@StringRes get() = when (this) {
|
||||
WorkStateFilter.OPEN -> R.string.work_state_open
|
||||
WorkStateFilter.CLOSED -> R.string.work_state_closed
|
||||
}
|
||||
|
||||
private val WorkTypeFilter.label: Int
|
||||
@StringRes get() = when (this) {
|
||||
WorkTypeFilter.ALL -> R.string.work_type_all
|
||||
WorkTypeFilter.ISSUES -> R.string.work_type_issues
|
||||
WorkTypeFilter.PULL_REQUESTS -> R.string.work_type_pull_requests
|
||||
}
|
||||
|
||||
private val WorkError.messageResource: Int
|
||||
@StringRes get() = when (this) {
|
||||
WorkError.OFFLINE -> R.string.work_offline
|
||||
WorkError.AUTHENTICATION_EXPIRED -> R.string.work_auth_expired
|
||||
WorkError.PERMISSION_DENIED -> R.string.work_permission_denied
|
||||
WorkError.SERVER -> R.string.work_server_error
|
||||
}
|
||||
@ -0,0 +1,137 @@
|
||||
package com.ubisam.ubgitea.feature.work
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ubisam.ubgitea.core.auth.android.AuthSession
|
||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||
import com.ubisam.ubgitea.core.network.OkHttpTransport
|
||||
import com.ubisam.ubgitea.core.settings.AppSettingsSession
|
||||
import com.ubisam.ubgitea.data.workitems.GiteaWorkSearchApi
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkItemHit
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkScope
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchRepository
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkStateFilter
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkTypeFilter
|
||||
import java.io.IOException
|
||||
import java.util.Collections
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class WorkError { OFFLINE, AUTHENTICATION_EXPIRED, PERMISSION_DENIED, SERVER }
|
||||
|
||||
sealed interface WorkUiState {
|
||||
val query: WorkSearchQuery
|
||||
|
||||
data class Idle(override val query: WorkSearchQuery = WorkSearchQuery()) : WorkUiState
|
||||
data class Loading(override val query: WorkSearchQuery) : WorkUiState
|
||||
class Content(
|
||||
override val query: WorkSearchQuery,
|
||||
items: List<WorkItemHit>,
|
||||
val page: Int,
|
||||
val hasNextPage: Boolean,
|
||||
/** Null when the server did not report how many items match. */
|
||||
val totalCount: Int?,
|
||||
) : WorkUiState {
|
||||
val items: List<WorkItemHit> = Collections.unmodifiableList(items.toList())
|
||||
}
|
||||
data class Empty(override val query: WorkSearchQuery) : WorkUiState
|
||||
data class Error(override val query: WorkSearchQuery, val type: WorkError) : WorkUiState
|
||||
}
|
||||
|
||||
/** Issues and pull requests across every repository, rather than one repository at a time. */
|
||||
class WorkViewModel(private val repository: WorkSearchRepository) : ViewModel() {
|
||||
private val mutableState = MutableStateFlow<WorkUiState>(WorkUiState.Idle())
|
||||
val state: StateFlow<WorkUiState> = mutableState.asStateFlow()
|
||||
private var operation: Job? = null
|
||||
|
||||
/** Loads the list unless one is already on screen; switching back to the tab must not refetch. */
|
||||
fun open() {
|
||||
if (mutableState.value !is WorkUiState.Idle) return
|
||||
load(mutableState.value.query, page = 1)
|
||||
}
|
||||
|
||||
/** Opens the tab on a specific scope, as the home screen's "needs my attention" card does. */
|
||||
fun open(scope: WorkScope) = load(WorkSearchQuery(scope = scope), page = 1)
|
||||
|
||||
fun refresh() = load(mutableState.value.query, page = 1)
|
||||
|
||||
fun selectScope(scope: WorkScope) = changeQuery { it.copy(scope = scope) }
|
||||
|
||||
fun selectState(state: WorkStateFilter) = changeQuery { it.copy(state = state) }
|
||||
|
||||
fun selectType(type: WorkTypeFilter) = changeQuery { it.copy(type = type) }
|
||||
|
||||
fun nextPage() {
|
||||
val content = mutableState.value as? WorkUiState.Content ?: return
|
||||
if (content.hasNextPage) load(content.query, content.page + 1)
|
||||
}
|
||||
|
||||
fun previousPage() {
|
||||
val content = mutableState.value as? WorkUiState.Content ?: return
|
||||
if (content.page > 1) load(content.query, content.page - 1)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
operation?.cancel()
|
||||
mutableState.value = WorkUiState.Idle()
|
||||
}
|
||||
|
||||
private fun changeQuery(transform: (WorkSearchQuery) -> WorkSearchQuery) {
|
||||
val current = mutableState.value.query
|
||||
val changed = transform(current)
|
||||
if (changed == current && mutableState.value !is WorkUiState.Error) return
|
||||
load(changed, page = 1)
|
||||
}
|
||||
|
||||
private fun load(query: WorkSearchQuery, page: Int) {
|
||||
operation?.cancel()
|
||||
mutableState.value = WorkUiState.Loading(query)
|
||||
operation = viewModelScope.launch {
|
||||
try {
|
||||
val result = repository.search(query, page, PAGE_SIZE)
|
||||
mutableState.value = if (result.items.isEmpty() && page == 1) {
|
||||
WorkUiState.Empty(query)
|
||||
} else {
|
||||
WorkUiState.Content(query, result.items, page, result.nextPage != null, result.totalCount)
|
||||
}
|
||||
} catch (failure: Throwable) {
|
||||
if (failure is CancellationException) throw failure
|
||||
mutableState.value = WorkUiState.Error(query, failure.toWorkError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(private val application: Application) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
require(modelClass.isAssignableFrom(WorkViewModel::class.java))
|
||||
return WorkViewModel(
|
||||
GiteaWorkSearchApi(
|
||||
UBGiteaOAuthDefaults.config,
|
||||
OkHttpTransport(),
|
||||
AuthSession.tokenProvider(application),
|
||||
reactiveAuthRetryEnabled = { AppSettingsSession.store(application).isReactiveAuthRetryEnabled() },
|
||||
),
|
||||
) as T
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PAGE_SIZE = 20
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Throwable.toWorkError(): WorkError = when (this) {
|
||||
is ApiFailure.AuthenticationExpired -> WorkError.AUTHENTICATION_EXPIRED
|
||||
is ApiFailure.PermissionDenied -> WorkError.PERMISSION_DENIED
|
||||
is ApiFailure.Network, is IOException -> WorkError.OFFLINE
|
||||
else -> WorkError.SERVER
|
||||
}
|
||||
@ -5,7 +5,6 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@ -15,18 +14,16 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
@ -39,11 +36,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.domain.workitems.CommentSummary
|
||||
@ -56,6 +57,18 @@ import com.ubisam.ubgitea.domain.workitems.ReviewSummary
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkItemAuthor
|
||||
import com.ubisam.ubgitea.ui.PaginationButtonHeight
|
||||
import com.ubisam.ubgitea.ui.SimplePaginationFooter
|
||||
import com.ubisam.ubgitea.ui.components.ActivityTone
|
||||
import com.ubisam.ubgitea.ui.components.Avatar
|
||||
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||
import com.ubisam.ubgitea.ui.components.KindTile
|
||||
import com.ubisam.ubgitea.ui.components.ListCard
|
||||
import com.ubisam.ubgitea.ui.components.MonoChip
|
||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||
import com.ubisam.ubgitea.ui.components.SectionTitle
|
||||
import com.ubisam.ubgitea.ui.components.StatusBadge
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
import com.ubisam.ubgitea.ui.formatRelative
|
||||
|
||||
@Composable
|
||||
fun WorkItemsScreen(
|
||||
@ -74,70 +87,117 @@ fun WorkItemsScreen(
|
||||
onAuthenticationExpired: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
when (state) {
|
||||
WorkItemsUiState.Idle -> Unit
|
||||
is WorkItemsUiState.Loading -> {
|
||||
BackHandler(onBack = onBack)
|
||||
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
|
||||
CenterMessage { CircularProgressIndicator() }
|
||||
}
|
||||
is WorkItemsUiState.IssueList -> {
|
||||
BackHandler(onBack = onBack)
|
||||
ModeTabs(WorkItemsTab.ISSUES, onShowIssues, onShowPullRequests)
|
||||
LazyColumn(Modifier.weight(1f)) {
|
||||
items(state.items, key = IssueSummary::number) { issue ->
|
||||
IssueRow(issue) { onIssueSelected(issue) }
|
||||
}
|
||||
Column(modifier.fillMaxSize()) {
|
||||
when (state) {
|
||||
WorkItemsUiState.Idle -> Unit
|
||||
is WorkItemsUiState.Loading -> {
|
||||
BackHandler(onBack = onBack)
|
||||
ListHeader(state.target, onBack)
|
||||
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
|
||||
CenteredLoading(null)
|
||||
}
|
||||
is WorkItemsUiState.IssueList -> {
|
||||
BackHandler(onBack = onBack)
|
||||
ListHeader(state.target, onBack)
|
||||
ModeTabs(WorkItemsTab.ISSUES, onShowIssues, onShowPullRequests)
|
||||
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||
itemsIndexed(state.items, key = { _, issue -> issue.number }) { index, issue ->
|
||||
WorkItemRow(
|
||||
icon = R.drawable.ic_ub_issue,
|
||||
tone = issue.state.tone(),
|
||||
number = issue.number,
|
||||
title = issue.title,
|
||||
statusLabel = stringResource(issue.state.labelResource),
|
||||
author = issue.author.label(),
|
||||
updatedAtEpochMillis = issue.updatedAtEpochMillis,
|
||||
commentCount = issue.commentCount,
|
||||
isFirst = index == 0,
|
||||
isLast = index == state.items.lastIndex,
|
||||
onSelected = { onIssueSelected(issue) },
|
||||
)
|
||||
}
|
||||
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
|
||||
}
|
||||
is WorkItemsUiState.PullRequestList -> {
|
||||
BackHandler(onBack = onBack)
|
||||
ModeTabs(WorkItemsTab.PULL_REQUESTS, onShowIssues, onShowPullRequests)
|
||||
LazyColumn(Modifier.weight(1f)) {
|
||||
items(state.items, key = PullRequestSummary::number) { pr ->
|
||||
PullRequestRow(pr) { onPullRequestSelected(pr) }
|
||||
}
|
||||
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
|
||||
}
|
||||
is WorkItemsUiState.PullRequestList -> {
|
||||
BackHandler(onBack = onBack)
|
||||
ListHeader(state.target, onBack)
|
||||
ModeTabs(WorkItemsTab.PULL_REQUESTS, onShowIssues, onShowPullRequests)
|
||||
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||
itemsIndexed(state.items, key = { _, pr -> pr.number }) { index, pr ->
|
||||
WorkItemRow(
|
||||
icon = if (pr.state == PullRequestState.MERGED) R.drawable.ic_ub_merge else R.drawable.ic_ub_pull_request,
|
||||
tone = pr.state.tone(),
|
||||
number = pr.number,
|
||||
title = pr.title,
|
||||
statusLabel = stringResource(pr.state.labelResource),
|
||||
author = pr.author.label(),
|
||||
updatedAtEpochMillis = pr.updatedAtEpochMillis,
|
||||
commentCount = pr.commentCount,
|
||||
isFirst = index == 0,
|
||||
isLast = index == state.items.lastIndex,
|
||||
onSelected = { onPullRequestSelected(pr) },
|
||||
)
|
||||
}
|
||||
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
|
||||
}
|
||||
is WorkItemsUiState.Empty -> {
|
||||
BackHandler(onBack = onBack)
|
||||
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
|
||||
CenterMessage { Text(stringResource(R.string.workitems_empty)) }
|
||||
}
|
||||
is WorkItemsUiState.ListError -> {
|
||||
BackHandler(onBack = onBack)
|
||||
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
|
||||
ErrorMessage(state.type, onRetryList, onAuthenticationExpired)
|
||||
}
|
||||
is WorkItemsUiState.IssueDetailLoading -> {
|
||||
BackHandler(onBack = onBackToList)
|
||||
CenterMessage { CircularProgressIndicator() }
|
||||
}
|
||||
is WorkItemsUiState.IssueDetailContent -> {
|
||||
BackHandler(onBack = onBackToList)
|
||||
IssueDetailView(state)
|
||||
}
|
||||
is WorkItemsUiState.PullRequestDetailLoading -> {
|
||||
BackHandler(onBack = onBackToList)
|
||||
CenterMessage { CircularProgressIndicator() }
|
||||
}
|
||||
is WorkItemsUiState.PullRequestDetailContent -> {
|
||||
BackHandler(onBack = onBackToList)
|
||||
PullRequestDetailView(state)
|
||||
}
|
||||
is WorkItemsUiState.DetailError -> {
|
||||
BackHandler(onBack = onBackToList)
|
||||
ErrorMessage(state.type, onRetryDetail, onAuthenticationExpired)
|
||||
}
|
||||
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
|
||||
}
|
||||
is WorkItemsUiState.Empty -> {
|
||||
BackHandler(onBack = onBack)
|
||||
ListHeader(state.target, onBack)
|
||||
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
|
||||
CenteredMessage(stringResource(R.string.workitems_empty))
|
||||
}
|
||||
is WorkItemsUiState.ListError -> {
|
||||
BackHandler(onBack = onBack)
|
||||
ListHeader(state.target, onBack)
|
||||
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
|
||||
ErrorMessage(state.type, onRetryList, onAuthenticationExpired)
|
||||
}
|
||||
is WorkItemsUiState.IssueDetailLoading -> {
|
||||
BackHandler(onBack = onBackToList)
|
||||
DetailHeader(state.target, state.number, onBackToList)
|
||||
CenteredLoading(null)
|
||||
}
|
||||
is WorkItemsUiState.IssueDetailContent -> {
|
||||
BackHandler(onBack = onBackToList)
|
||||
DetailHeader(state.target, state.detail.number, onBackToList)
|
||||
IssueDetailView(state)
|
||||
}
|
||||
is WorkItemsUiState.PullRequestDetailLoading -> {
|
||||
BackHandler(onBack = onBackToList)
|
||||
DetailHeader(state.target, state.number, onBackToList)
|
||||
CenteredLoading(null)
|
||||
}
|
||||
is WorkItemsUiState.PullRequestDetailContent -> {
|
||||
BackHandler(onBack = onBackToList)
|
||||
DetailHeader(state.target, state.detail.number, onBackToList)
|
||||
PullRequestDetailView(state)
|
||||
}
|
||||
is WorkItemsUiState.DetailError -> {
|
||||
BackHandler(onBack = onBackToList)
|
||||
DetailHeader(state.target, state.number, onBackToList)
|
||||
ErrorMessage(state.type, onRetryDetail, onAuthenticationExpired)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListHeader(target: WorkItemsTarget, onBack: () -> Unit) {
|
||||
ScreenTopBar(title = target.repository.name, onBack = onBack, subtitle = target.repository.owner)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailHeader(target: WorkItemsTarget, number: Long, onBack: () -> Unit) {
|
||||
ScreenTopBar(
|
||||
title = stringResource(R.string.workitems_number, number),
|
||||
onBack = onBack,
|
||||
subtitle = target.repository.name,
|
||||
monospaceTitle = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeTabs(tab: WorkItemsTab, onShowIssues: () -> Unit, onShowPullRequests: () -> Unit) {
|
||||
TabRow(selectedTabIndex = if (tab == WorkItemsTab.PULL_REQUESTS) 1 else 0) {
|
||||
@ -155,119 +215,243 @@ private fun ModeTabs(tab: WorkItemsTab, onShowIssues: () -> Unit, onShowPullRequ
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IssueRow(issue: IssueSummary, onSelected: () -> Unit) {
|
||||
Column(Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatusBadge(issue.state)
|
||||
Text("#${issue.number}", style = MaterialTheme.typography.bodySmall)
|
||||
private fun WorkItemRow(
|
||||
icon: Int,
|
||||
tone: ActivityTone,
|
||||
number: Long,
|
||||
title: String,
|
||||
statusLabel: String,
|
||||
author: String,
|
||||
updatedAtEpochMillis: Long,
|
||||
commentCount: Int,
|
||||
isFirst: Boolean,
|
||||
isLast: Boolean,
|
||||
onSelected: () -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (isFirst) 20.dp else 0.dp,
|
||||
topEnd = if (isFirst) 20.dp else 0.dp,
|
||||
bottomStart = if (isLast) 20.dp else 0.dp,
|
||||
bottomEnd = if (isLast) 20.dp else 0.dp,
|
||||
)
|
||||
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
|
||||
Column {
|
||||
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable(role = Role.Button, onClick = onSelected)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
KindTile(icon, tone)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatusBadge(statusLabel, tone)
|
||||
Text(
|
||||
stringResource(R.string.workitems_number, number),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(R.string.work_meta, author, formatRelative(updatedAtEpochMillis)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
val comments = stringResource(R.string.work_comments_description, commentCount)
|
||||
Row(
|
||||
Modifier.semantics(mergeDescendants = true) { contentDescription = comments },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
UBIcon(R.drawable.ic_ub_comment, null, size = 14.dp, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
commentCount.toString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(issue.title, style = MaterialTheme.typography.titleMedium, maxLines = 2)
|
||||
Text(stringResource(R.string.workitems_by_author, issue.author.label(), issue.commentCount), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PullRequestRow(pr: PullRequestSummary, onSelected: () -> Unit) {
|
||||
Column(Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatusBadge(pr.state)
|
||||
Text("#${pr.number}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text(pr.title, style = MaterialTheme.typography.titleMedium, maxLines = 2)
|
||||
Text(stringResource(R.string.workitems_by_author, pr.author.label(), pr.commentCount), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IssueDetailView(state: WorkItemsUiState.IssueDetailContent) {
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
StatusBadge(state.detail.state, Modifier.padding(bottom = 4.dp))
|
||||
Text(state.detail.title, style = MaterialTheme.typography.titleLarge)
|
||||
Text(stringResource(R.string.workitems_author, state.detail.author.label()), style = MaterialTheme.typography.bodySmall)
|
||||
if (state.detail.labels.isNotEmpty()) {
|
||||
Text(state.detail.labels.joinToString(", "), style = MaterialTheme.typography.bodySmall)
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||
item(key = "header") {
|
||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatusBadge(stringResource(state.detail.state.labelResource), state.detail.state.tone())
|
||||
Text(
|
||||
stringResource(R.string.workitems_number, state.detail.number),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(state.detail.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Avatar(state.detail.author.label(), size = 28.dp)
|
||||
Text(state.detail.author.label(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
formatRelative(state.detail.updatedAtEpochMillis),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (state.detail.labels.isNotEmpty()) {
|
||||
Text(
|
||||
state.detail.labels.joinToString(", "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (state.detail.assignees.isNotEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.workitems_assignees, state.detail.assignees.joinToString(", ")),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (state.detail.body.isNotBlank()) {
|
||||
Text(state.detail.body, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
if (state.detail.assignees.isNotEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.workitems_assignees, state.detail.assignees.joinToString(", ")),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(state.detail.body, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
items(state.comments, key = CommentSummary::id) { CommentRow(it) }
|
||||
if (state.comments.isEmpty()) item { CenterMessage { Text(stringResource(R.string.workitems_no_comments)) } }
|
||||
item(key = "comments-title") { SectionTitle(stringResource(R.string.workitems_comments_title), Modifier.padding(top = 8.dp)) }
|
||||
if (state.comments.isEmpty()) {
|
||||
item(key = "comments-empty") { CenteredMessage(stringResource(R.string.workitems_no_comments), Modifier.padding(vertical = 8.dp)) }
|
||||
} else {
|
||||
items(state.comments, key = CommentSummary::id) { comment -> CommentCard(comment) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PullRequestDetailView(state: WorkItemsUiState.PullRequestDetailContent) {
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
StatusBadge(state.detail.state, Modifier.padding(bottom = 4.dp))
|
||||
Text(state.detail.title, style = MaterialTheme.typography.titleLarge)
|
||||
Text(stringResource(R.string.workitems_author, state.detail.author.label()), style = MaterialTheme.typography.bodySmall)
|
||||
Text(stringResource(R.string.workitems_pr_refs, state.detail.baseRef, state.detail.headRef), style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
stringResource(if (state.detail.isMergeable) R.string.workitems_mergeable else R.string.workitems_not_mergeable),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(state.detail.body, modifier = Modifier.padding(top = 12.dp))
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||
item(key = "header") {
|
||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatusBadge(stringResource(state.detail.state.labelResource), state.detail.state.tone())
|
||||
Text(
|
||||
stringResource(R.string.workitems_number, state.detail.number),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(state.detail.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Avatar(state.detail.author.label(), size = 28.dp)
|
||||
Text(state.detail.author.label(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
formatRelative(state.detail.updatedAtEpochMillis),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
MonoChip(state.detail.headRef, icon = R.drawable.ic_ub_branch)
|
||||
UBIcon(R.drawable.ic_ub_chevron_right, null, size = 16.dp, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
MonoChip(state.detail.baseRef, icon = R.drawable.ic_ub_branch)
|
||||
}
|
||||
StatusBadge(
|
||||
stringResource(if (state.detail.isMergeable) R.string.workitems_mergeable else R.string.workitems_not_mergeable),
|
||||
if (state.detail.isMergeable) ActivityTone.SUCCESS else ActivityTone.WARNING,
|
||||
)
|
||||
if (state.detail.body.isNotBlank()) {
|
||||
Text(state.detail.body, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.workitems_reviews_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
item(key = "reviews-title") { SectionTitle(stringResource(R.string.workitems_reviews_title), Modifier.padding(top = 8.dp)) }
|
||||
if (state.reviews.isEmpty()) {
|
||||
item(key = "reviews-empty") { CenteredMessage(stringResource(R.string.workitems_no_reviews), Modifier.padding(vertical = 8.dp)) }
|
||||
} else {
|
||||
items(state.reviews, key = ReviewSummary::id) { review -> ReviewCard(review) }
|
||||
}
|
||||
item(key = "comments-title") { SectionTitle(stringResource(R.string.workitems_comments_title), Modifier.padding(top = 8.dp)) }
|
||||
if (state.comments.isEmpty()) {
|
||||
item(key = "comments-empty") { CenteredMessage(stringResource(R.string.workitems_no_comments), Modifier.padding(vertical = 8.dp)) }
|
||||
} else {
|
||||
items(state.comments, key = CommentSummary::id) { comment -> CommentCard(comment) }
|
||||
}
|
||||
items(state.reviews, key = ReviewSummary::id) { ReviewRow(it) }
|
||||
if (state.reviews.isEmpty()) item { CenterMessage { Text(stringResource(R.string.workitems_no_reviews)) } }
|
||||
item { HorizontalDivider() }
|
||||
items(state.comments, key = CommentSummary::id) { CommentRow(it) }
|
||||
if (state.comments.isEmpty()) item { CenterMessage { Text(stringResource(R.string.workitems_no_comments)) } }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReviewRow(review: ReviewSummary) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(review.author.label(), style = MaterialTheme.typography.bodySmall)
|
||||
Text(stringResource(review.state.labelResource), style = MaterialTheme.typography.bodySmall)
|
||||
private fun ReviewCard(review: ReviewSummary) {
|
||||
ListCard(Modifier.padding(horizontal = 20.dp, vertical = 6.dp)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Avatar(review.author.label(), size = 28.dp)
|
||||
Text(
|
||||
review.author.label(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
review.submittedAtEpochMillis?.let {
|
||||
Text(formatRelative(it), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
StatusBadge(stringResource(review.state.labelResource), review.state.tone())
|
||||
if (review.body.isNotBlank()) Text(review.body, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
if (review.body.isNotBlank()) Text(review.body, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommentRow(comment: CommentSummary) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Text(comment.author.label(), style = MaterialTheme.typography.bodySmall)
|
||||
Text(comment.body, style = MaterialTheme.typography.bodyMedium)
|
||||
private fun CommentCard(comment: CommentSummary) {
|
||||
ListCard(Modifier.padding(horizontal = 20.dp, vertical = 6.dp)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Avatar(comment.author.label(), size = 28.dp)
|
||||
Text(
|
||||
comment.author.label(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
formatRelative(comment.createdAtEpochMillis),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (comment.body.isNotBlank()) Text(comment.body, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorMessage(type: WorkItemsError, onRetry: () -> Unit, onAuthenticationExpired: () -> Unit) {
|
||||
CenterMessage {
|
||||
Text(stringResource(type.messageResource))
|
||||
if (type == WorkItemsError.AUTHENTICATION_EXPIRED) {
|
||||
Button(onClick = onAuthenticationExpired) { Text(stringResource(R.string.workitems_sign_in_again)) }
|
||||
} else {
|
||||
Button(onClick = onRetry) { Text(stringResource(R.string.workitems_retry)) }
|
||||
}
|
||||
}
|
||||
val expired = type == WorkItemsError.AUTHENTICATION_EXPIRED
|
||||
CenteredMessage(
|
||||
stringResource(type.messageResource),
|
||||
action = stringResource(if (expired) R.string.workitems_sign_in_again else R.string.workitems_retry),
|
||||
onAction = if (expired) onAuthenticationExpired else onRetry,
|
||||
)
|
||||
}
|
||||
|
||||
// Issues/PRs are the one list in this app where the server actually reports a total match count
|
||||
@ -390,16 +574,6 @@ private fun pageWindow(current: Int, totalPages: Int, windowSize: Int = 5): IntR
|
||||
return start..(start + windowSize - 1)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterMessage(content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorkItemAuthor.label(): String = when (this) {
|
||||
is WorkItemAuthor.Known -> displayName?.takeIf(String::isNotBlank) ?: login
|
||||
@ -412,6 +586,11 @@ private val IssueState.labelResource: Int
|
||||
IssueState.CLOSED -> R.string.workitems_issue_closed
|
||||
}
|
||||
|
||||
private fun IssueState.tone(): ActivityTone = when (this) {
|
||||
IssueState.OPEN -> ActivityTone.SUCCESS
|
||||
IssueState.CLOSED -> ActivityTone.DANGER
|
||||
}
|
||||
|
||||
private val PullRequestState.labelResource: Int
|
||||
get() = when (this) {
|
||||
PullRequestState.OPEN -> R.string.workitems_pr_open
|
||||
@ -419,48 +598,10 @@ private val PullRequestState.labelResource: Int
|
||||
PullRequestState.MERGED -> R.string.workitems_pr_merged
|
||||
}
|
||||
|
||||
// 열림/닫힘/병합됨 used to render as a plain bodySmall line indistinguishable in weight from the
|
||||
// "#12" issue number next to it -- easy to miss when scanning a list. This reuses the same compact
|
||||
// colored-Surface badge treatment RepositoryListScreen's VisibilityBadge already established for
|
||||
// 공개/비공개, borrowing existing theme roles rather than inventing new colors: OPEN gets the app's
|
||||
// green primaryContainer (this app's one "affirmative" accent, same one VisibilityBadge uses for a
|
||||
// public repository), CLOSED gets the baseline Material3 errorContainer (matches how "closed"/
|
||||
// "removed" states are treated elsewhere per Theme.kt's own doc comment), and MERGED -- a third,
|
||||
// visually distinct state GitHub/Gitea conventionally colors purple, which this theme has no role
|
||||
// for -- gets the teal tertiaryContainer instead of introducing an unthemed one-off color.
|
||||
@Composable
|
||||
private fun StatusBadge(state: IssueState, modifier: Modifier = Modifier) {
|
||||
val (container, content) = when (state) {
|
||||
IssueState.OPEN -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
|
||||
IssueState.CLOSED -> MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
|
||||
}
|
||||
StatusBadge(stringResource(state.labelResource), container, content, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusBadge(state: PullRequestState, modifier: Modifier = Modifier) {
|
||||
val (container, content) = when (state) {
|
||||
PullRequestState.OPEN -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
|
||||
PullRequestState.CLOSED -> MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
|
||||
PullRequestState.MERGED -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer
|
||||
}
|
||||
StatusBadge(stringResource(state.labelResource), container, content, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusBadge(label: String, containerColor: Color, contentColor: Color, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
private fun PullRequestState.tone(): ActivityTone = when (this) {
|
||||
PullRequestState.OPEN -> ActivityTone.SUCCESS
|
||||
PullRequestState.CLOSED -> ActivityTone.DANGER
|
||||
PullRequestState.MERGED -> ActivityTone.MERGED
|
||||
}
|
||||
|
||||
private val ReviewState.labelResource: Int
|
||||
@ -473,6 +614,15 @@ private val ReviewState.labelResource: Int
|
||||
ReviewState.OTHER -> R.string.workitems_review_other
|
||||
}
|
||||
|
||||
private fun ReviewState.tone(): ActivityTone = when (this) {
|
||||
ReviewState.APPROVED -> ActivityTone.SUCCESS
|
||||
ReviewState.REQUEST_CHANGES -> ActivityTone.DANGER
|
||||
ReviewState.PENDING -> ActivityTone.WARNING
|
||||
ReviewState.REQUEST_REVIEW -> ActivityTone.WARNING
|
||||
ReviewState.COMMENT -> ActivityTone.BRAND
|
||||
ReviewState.OTHER -> ActivityTone.NEUTRAL
|
||||
}
|
||||
|
||||
private val WorkItemsError.messageResource: Int
|
||||
get() = when (this) {
|
||||
WorkItemsError.OFFLINE -> R.string.workitems_offline
|
||||
|
||||
@ -133,6 +133,11 @@ class WorkItemsViewModel(private val repository: WorkItemsRepository) : ViewMode
|
||||
loadPullRequestDetail(target, pullRequest.number)
|
||||
}
|
||||
|
||||
/** Opens one issue or pull request directly, as a notification or a search result does. */
|
||||
fun openItem(target: WorkItemsTarget, number: Long, isPullRequest: Boolean) {
|
||||
if (isPullRequest) loadPullRequestDetail(target, number) else loadIssueDetail(target, number)
|
||||
}
|
||||
|
||||
fun retryDetail() {
|
||||
val current = mutableState.value as? WorkItemsUiState.DetailError ?: return
|
||||
when (current.tab) {
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
package com.ubisam.ubgitea.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.ubisam.ubgitea.R
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
@ -9,6 +13,48 @@ import java.util.Locale
|
||||
private val instantFormatter: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm (zzz)", Locale.KOREAN)
|
||||
|
||||
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.KOREAN)
|
||||
|
||||
private val headlineDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("M월 d일 EEEE", Locale.KOREAN)
|
||||
|
||||
/** Formats an epoch-millis instant in the device's local time zone, with the zone shown explicitly. */
|
||||
fun formatInstant(epochMillis: Long): String =
|
||||
ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()).format(instantFormatter)
|
||||
|
||||
fun formatHeadlineDate(date: LocalDate): String = date.format(headlineDateFormatter)
|
||||
|
||||
/** How long ago something happened, as a coarse bucket. Anything a week or older is a plain date. */
|
||||
sealed interface RelativeTime {
|
||||
data object JustNow : RelativeTime
|
||||
data class Minutes(val count: Int) : RelativeTime
|
||||
data class Hours(val count: Int) : RelativeTime
|
||||
data object Yesterday : RelativeTime
|
||||
data class Days(val count: Int) : RelativeTime
|
||||
data class OnDate(val text: String) : RelativeTime
|
||||
}
|
||||
|
||||
fun relativeTimeOf(epochMillis: Long, nowEpochMillis: Long, zone: ZoneId = ZoneId.systemDefault()): RelativeTime {
|
||||
val elapsedMinutes = (nowEpochMillis - epochMillis) / 60_000L
|
||||
val then = Instant.ofEpochMilli(epochMillis).atZone(zone).toLocalDate()
|
||||
val today = Instant.ofEpochMilli(nowEpochMillis).atZone(zone).toLocalDate()
|
||||
val daysApart = today.toEpochDay() - then.toEpochDay()
|
||||
return when {
|
||||
elapsedMinutes < 1 -> RelativeTime.JustNow
|
||||
elapsedMinutes < 60 -> RelativeTime.Minutes(elapsedMinutes.toInt())
|
||||
daysApart <= 0L -> RelativeTime.Hours((elapsedMinutes / 60).toInt())
|
||||
daysApart == 1L -> RelativeTime.Yesterday
|
||||
daysApart < 7L -> RelativeTime.Days(daysApart.toInt())
|
||||
else -> RelativeTime.OnDate(then.format(dateFormatter))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun formatRelative(epochMillis: Long, nowEpochMillis: Long = System.currentTimeMillis()): String =
|
||||
when (val relative = relativeTimeOf(epochMillis, nowEpochMillis)) {
|
||||
RelativeTime.JustNow -> stringResource(R.string.time_just_now)
|
||||
is RelativeTime.Minutes -> stringResource(R.string.time_minutes_ago, relative.count)
|
||||
is RelativeTime.Hours -> stringResource(R.string.time_hours_ago, relative.count)
|
||||
RelativeTime.Yesterday -> stringResource(R.string.time_yesterday)
|
||||
is RelativeTime.Days -> stringResource(R.string.time_days_ago, relative.count)
|
||||
is RelativeTime.OnDate -> relative.text
|
||||
}
|
||||
|
||||
207
app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt
Normal file
207
app/src/main/java/com/ubisam/ubgitea/ui/components/Elements.kt
Normal file
@ -0,0 +1,207 @@
|
||||
package com.ubisam.ubgitea.ui.components
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.LineBreak
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||
|
||||
/** One of the app's own stroke icons (res/drawable/ic_ub_*), tinted with the current content color. */
|
||||
@Composable
|
||||
fun UBIcon(
|
||||
@DrawableRes id: Int,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = LocalContentColor.current,
|
||||
size: Dp = 24.dp,
|
||||
) {
|
||||
Icon(painterResource(id), contentDescription, modifier.size(size), tint)
|
||||
}
|
||||
|
||||
/** Initials on the brand blue. The text color is fixed dark: white on this blue is below 4.5:1. */
|
||||
@Composable
|
||||
fun Avatar(name: String, modifier: Modifier = Modifier, size: Dp = 36.dp, contentDescription: String? = null) {
|
||||
val initials = initialsOf(name)
|
||||
Box(
|
||||
modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(UBGiteaTheme.colors.brand)
|
||||
.semantics { if (contentDescription != null) this.contentDescription = contentDescription },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(initials, color = AvatarText, fontWeight = FontWeight.Bold, fontSize = (size.value * 0.38f).sp)
|
||||
}
|
||||
}
|
||||
|
||||
private val AvatarText = Color(0xFF04213F)
|
||||
|
||||
internal fun initialsOf(name: String): String {
|
||||
val parts = name.trim().split(' ', '.', '_', '-').filter(String::isNotBlank)
|
||||
return when {
|
||||
parts.isEmpty() -> "?"
|
||||
parts.size == 1 -> parts[0].take(2).uppercase()
|
||||
else -> (parts[0].take(1) + parts[1].take(1)).uppercase()
|
||||
}
|
||||
}
|
||||
|
||||
enum class ActivityTone { BRAND, SUCCESS, MERGED, DANGER, NEUTRAL, WARNING }
|
||||
|
||||
@Composable
|
||||
fun ActivityTone.containerColor(): Color = when (this) {
|
||||
ActivityTone.BRAND -> MaterialTheme.colorScheme.primaryContainer
|
||||
ActivityTone.SUCCESS -> UBGiteaTheme.colors.successContainer
|
||||
ActivityTone.MERGED -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
ActivityTone.DANGER -> MaterialTheme.colorScheme.errorContainer
|
||||
ActivityTone.NEUTRAL -> MaterialTheme.colorScheme.surfaceVariant
|
||||
ActivityTone.WARNING -> UBGiteaTheme.colors.warningContainer
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActivityTone.contentColor(): Color = when (this) {
|
||||
ActivityTone.BRAND -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
ActivityTone.SUCCESS -> UBGiteaTheme.colors.onSuccessContainer
|
||||
ActivityTone.MERGED -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
ActivityTone.DANGER -> MaterialTheme.colorScheme.onErrorContainer
|
||||
ActivityTone.NEUTRAL -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
ActivityTone.WARNING -> UBGiteaTheme.colors.onWarningContainer
|
||||
}
|
||||
|
||||
/** A rounded square holding the icon that says what kind of thing a row is. */
|
||||
@Composable
|
||||
fun KindTile(@DrawableRes icon: Int, tone: ActivityTone, modifier: Modifier = Modifier, size: Dp = 36.dp) {
|
||||
Box(
|
||||
modifier.size(size).clip(RoundedCornerShape(12.dp)).background(tone.containerColor()),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
UBIcon(icon, null, tint = tone.contentColor(), size = 20.dp)
|
||||
}
|
||||
}
|
||||
|
||||
/** A state label. The label text carries the meaning; the color only reinforces it. */
|
||||
@Composable
|
||||
fun StatusBadge(label: String, tone: ActivityTone, modifier: Modifier = Modifier) {
|
||||
Surface(color = tone.containerColor(), contentColor = tone.contentColor(), shape = RoundedCornerShape(10.dp), modifier = modifier) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A branch name, commit id or similar identifier: monospace, with the icon that says which. */
|
||||
@Composable
|
||||
fun MonoChip(text: String, modifier: Modifier = Modifier, @DrawableRes icon: Int? = null) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (icon != null) UBIcon(icon, null, size = 14.dp)
|
||||
Text(text, fontFamily = FontFamily.Monospace, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A white card on the tinted page: the container every list on a tab sits in. */
|
||||
@Composable
|
||||
fun ListCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CenteredLoading(message: String?, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
if (message != null) Text(message, modifier = Modifier.padding(top = 16.dp), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CenteredMessage(
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
action: String? = null,
|
||||
onAction: () -> Unit = {},
|
||||
secondaryAction: String? = null,
|
||||
onSecondaryAction: () -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
// Korean breaks between any two characters by default; this keeps words whole.
|
||||
Text(message, style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Heading), textAlign = TextAlign.Center)
|
||||
if (action != null) Button(onClick = onAction, modifier = Modifier.padding(top = 20.dp)) { Text(action) }
|
||||
if (secondaryAction != null) {
|
||||
OutlinedButton(onClick = onSecondaryAction, modifier = Modifier.padding(top = 8.dp)) { Text(secondaryAction) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The caution strip shown above content that may be stale or incomplete. */
|
||||
@Composable
|
||||
fun CautionBanner(message: String, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
color = UBGiteaTheme.colors.warningContainer,
|
||||
contentColor = UBGiteaTheme.colors.onWarningContainer,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(message, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
@ -1,110 +1,185 @@
|
||||
package com.ubisam.ubgitea.ui.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ubisam.ubgitea.ui.theme.UbiSamLogoBlue
|
||||
import com.ubisam.ubgitea.ui.theme.UbiSamLogoGray
|
||||
import com.ubisam.ubgitea.R
|
||||
|
||||
/**
|
||||
* App-wide header: the UbiSam wordmark, small, top-left, on every top-level screen. Nested screens
|
||||
* (settings, notifications, branch detail, ...) keep the app's existing system-back-gesture
|
||||
* navigation as-is -- this bar is deliberately just a branding element, not a navigation bar, so it
|
||||
* carries no back button and doesn't need to know about any screen's back stack.
|
||||
* The header of a top-level tab: the UbiSam wordmark, which Gitea instance and account are active
|
||||
* (product.md: never obscure either), and the profile menu that holds settings and sign-out.
|
||||
*/
|
||||
@Composable
|
||||
fun UBGiteaTopBar(modifier: Modifier = Modifier, actions: @Composable RowScope.() -> Unit = {}) {
|
||||
fun BrandTopBar(
|
||||
accountName: String?,
|
||||
instanceHost: String,
|
||||
onViewSettings: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
UBGiteaLogo()
|
||||
Spacer(Modifier.weight(1f))
|
||||
actions()
|
||||
Column(Modifier.padding(start = 20.dp, end = 8.dp, top = 8.dp, bottom = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.logo_ubisam),
|
||||
contentDescription = stringResource(R.string.shell_logo_description),
|
||||
modifier = Modifier.height(20.dp),
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
actions()
|
||||
ProfileMenu(accountName, onViewSettings, onLogout)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
instanceHost,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (accountName != null) {
|
||||
Text(
|
||||
stringResource(R.string.shell_account_separator, accountName),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UBGiteaLogo(modifier: Modifier = Modifier) {
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Ubi",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = UbiSamLogoGray,
|
||||
)
|
||||
Text(
|
||||
text = "Sam",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = UbiSamLogoBlue,
|
||||
)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(UbiSamLogoBlue),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
SignalMark(color = Color.White, modifier = Modifier.size(11.dp))
|
||||
private fun ProfileMenu(accountName: String?, onViewSettings: () -> Unit, onLogout: () -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val description = stringResource(R.string.shell_profile_menu)
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Avatar(accountName.orEmpty(), size = 34.dp, contentDescription = description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stand-in for the real logo's signal/RSS mark, drawn rather than imported so the redesign needs
|
||||
// no new image asset: a dot plus two quarter-arcs, all centered on the bottom-left corner of the
|
||||
// box, matching the mark in the real UbiSam wordmark. Swap for an actual drawable resource if/when
|
||||
// the source image file is available.
|
||||
@Composable
|
||||
private fun SignalMark(color: Color, modifier: Modifier = Modifier) {
|
||||
Canvas(modifier = modifier) {
|
||||
val corner = Offset(0f, size.height)
|
||||
val stroke = size.minDimension * 0.22f
|
||||
drawCircle(
|
||||
color = color,
|
||||
radius = size.minDimension * 0.14f,
|
||||
center = Offset(size.minDimension * 0.14f, size.height - size.minDimension * 0.14f),
|
||||
)
|
||||
listOf(0.55f, 0.9f).forEach { fraction ->
|
||||
val radius = size.minDimension * fraction
|
||||
drawArc(
|
||||
color = color,
|
||||
startAngle = -90f,
|
||||
sweepAngle = 90f,
|
||||
useCenter = false,
|
||||
topLeft = Offset(corner.x - radius, corner.y - radius),
|
||||
size = Size(radius * 2f, radius * 2f),
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round),
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.repositories_settings)) },
|
||||
leadingIcon = { UBIcon(R.drawable.ic_ub_settings, null) },
|
||||
onClick = { expanded = false; onViewSettings() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.repositories_logout)) },
|
||||
leadingIcon = { UBIcon(R.drawable.ic_ub_logout, null) },
|
||||
onClick = { expanded = false; onLogout() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The header of every screen below a tab: a visible back button (the system back gesture still
|
||||
* works, but is no longer the only way out), a title, and where the screen sits.
|
||||
*/
|
||||
@Composable
|
||||
fun ScreenTopBar(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
monospaceTitle: Boolean = false,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
Row(
|
||||
Modifier.heightIn(min = 56.dp).padding(start = 4.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
UBIcon(R.drawable.ic_ub_back, stringResource(R.string.shell_back))
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(end = 8.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = if (monospaceTitle) FontFamily.Monospace else null,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
actions()
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The large title block directly under [BrandTopBar] on a tab. */
|
||||
@Composable
|
||||
fun TabTitle(title: String, modifier: Modifier = Modifier, subtitle: String? = null) {
|
||||
Column(modifier.padding(horizontal = 20.dp, vertical = 12.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionTitle(title: String, modifier: Modifier = Modifier, action: @Composable RowScope.() -> Unit = {}) {
|
||||
Row(modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f).padding(vertical = 10.dp).semantics { heading() },
|
||||
)
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,14 +7,27 @@ package com.ubisam.ubgitea.ui.navigation
|
||||
* after process death, consistent with the app's no-offline-cache posture.
|
||||
*/
|
||||
sealed interface Route {
|
||||
// The four tabs. A tab is always the bottom of the stack; choosing another tab replaces the
|
||||
// whole stack with that tab rather than pushing onto it.
|
||||
data object Home : Route
|
||||
data object Repositories : Route
|
||||
data object Work : Route
|
||||
data object Notifications : 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
|
||||
// number is set when the screen opens on one issue or pull request rather than on the list.
|
||||
data class WorkItems(
|
||||
val owner: String,
|
||||
val name: String,
|
||||
val number: Long? = null,
|
||||
val isPullRequest: Boolean = false,
|
||||
) : Route
|
||||
data object Settings : Route
|
||||
|
||||
val isTab: Boolean
|
||||
get() = this is Home || this is Repositories || this is Work || this is Notifications
|
||||
}
|
||||
|
||||
@ -15,12 +15,16 @@ val RouteStackSaver: Saver<List<Route>, Any> = listSaver(
|
||||
save = { stack: List<Route> -> stack.map { route -> RouteEncoding.encode(route) } },
|
||||
restore = { encoded: List<String> ->
|
||||
encoded.mapNotNull(RouteEncoding::decodeOrNull)
|
||||
.ifEmpty { listOf(Route.Repositories) }
|
||||
.ifEmpty { listOf(Route.Home) }
|
||||
},
|
||||
)
|
||||
|
||||
internal object RouteEncoding {
|
||||
private const val FIELD_SEPARATOR = ''
|
||||
private const val TYPE_HOME = "home"
|
||||
private const val TYPE_WORK = "work"
|
||||
private const val TYPE_WORK_ITEM_ISSUE = "issue"
|
||||
private const val TYPE_WORK_ITEM_PULL_REQUEST = "pull"
|
||||
private const val TYPE_REPOSITORIES = "repositories"
|
||||
private const val TYPE_BRANCH_DETAIL = "branchDetail"
|
||||
private const val TYPE_WORK_ITEMS = "workItems"
|
||||
@ -28,14 +32,26 @@ internal object RouteEncoding {
|
||||
private const val TYPE_SETTINGS = "settings"
|
||||
|
||||
fun encode(route: Route): String = when (route) {
|
||||
Route.Home -> TYPE_HOME
|
||||
Route.Repositories -> TYPE_REPOSITORIES
|
||||
Route.Work -> TYPE_WORK
|
||||
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)
|
||||
is Route.WorkItems -> if (route.number == null) {
|
||||
join(TYPE_WORK_ITEMS, route.owner, route.name)
|
||||
} else {
|
||||
join(
|
||||
TYPE_WORK_ITEMS,
|
||||
route.owner,
|
||||
route.name,
|
||||
if (route.isPullRequest) TYPE_WORK_ITEM_PULL_REQUEST else TYPE_WORK_ITEM_ISSUE,
|
||||
route.number.toString(),
|
||||
)
|
||||
}
|
||||
Route.Notifications -> TYPE_NOTIFICATIONS
|
||||
Route.Settings -> TYPE_SETTINGS
|
||||
}
|
||||
|
||||
fun decode(encoded: String): Route = decodeOrNull(encoded) ?: Route.Repositories
|
||||
fun decode(encoded: String): Route = decodeOrNull(encoded) ?: Route.Home
|
||||
|
||||
fun decodeOrNull(encoded: String): Route? {
|
||||
val parts = encoded.split(FIELD_SEPARATOR)
|
||||
@ -43,9 +59,19 @@ internal object RouteEncoding {
|
||||
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])
|
||||
TYPE_WORK_ITEMS -> when (parts.size) {
|
||||
3 -> Route.WorkItems(owner = parts[1], name = parts[2])
|
||||
5 -> parts[4].toLongOrNull()?.takeIf { it > 0 }?.let { number ->
|
||||
when (parts[3]) {
|
||||
TYPE_WORK_ITEM_ISSUE -> Route.WorkItems(parts[1], parts[2], number, isPullRequest = false)
|
||||
TYPE_WORK_ITEM_PULL_REQUEST -> Route.WorkItems(parts[1], parts[2], number, isPullRequest = true)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
TYPE_HOME -> Route.Home
|
||||
TYPE_WORK -> Route.Work
|
||||
TYPE_NOTIFICATIONS -> Route.Notifications
|
||||
TYPE_SETTINGS -> Route.Settings
|
||||
TYPE_REPOSITORIES -> Route.Repositories
|
||||
|
||||
139
app/src/main/java/com/ubisam/ubgitea/ui/shell/AppShell.kt
Normal file
139
app/src/main/java/com/ubisam/ubgitea/ui/shell/AppShell.kt
Normal file
@ -0,0 +1,139 @@
|
||||
package com.ubisam.ubgitea.ui.shell
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.NavigationRailItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||
|
||||
enum class AppTab(@StringRes val label: Int, @DrawableRes val icon: Int) {
|
||||
HOME(R.string.shell_tab_home, R.drawable.ic_ub_home),
|
||||
REPOSITORIES(R.string.shell_tab_repositories, R.drawable.ic_ub_repo),
|
||||
WORK(R.string.shell_tab_work, R.drawable.ic_ub_pull_request),
|
||||
NOTIFICATIONS(R.string.shell_tab_notifications, R.drawable.ic_ub_bell),
|
||||
}
|
||||
|
||||
/**
|
||||
* Android's "medium" width class starts here. A phone in portrait is below it; a Z Fold's inner
|
||||
* display (roughly 650-750dp wide, portrait) and a phone in landscape are at or above it, and get
|
||||
* a navigation rail and two-pane layouts instead of a bottom bar and single panes.
|
||||
*/
|
||||
val WideLayoutBreakpoint: Dp = 600.dp
|
||||
|
||||
/**
|
||||
* The frame around every screen: a bottom bar on a narrow window, a rail down the left edge on a
|
||||
* wide one, or neither when [showNavigation] is false (sign-in, and full-screen reading).
|
||||
*/
|
||||
@Composable
|
||||
fun AppShell(
|
||||
selectedTab: AppTab,
|
||||
showNavigation: Boolean,
|
||||
unreadNotifications: Int?,
|
||||
onTabSelected: (AppTab) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (isWide: Boolean) -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val isWide = maxWidth >= WideLayoutBreakpoint
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
bottomBar = {
|
||||
if (showNavigation && !isWide) BottomTabs(selectedTab, unreadNotifications, onTabSelected)
|
||||
},
|
||||
) { padding ->
|
||||
Row(Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding)) {
|
||||
if (showNavigation && isWide) {
|
||||
SideTabs(selectedTab, unreadNotifications, onTabSelected)
|
||||
VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) { content(isWide) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomTabs(selected: AppTab, unreadNotifications: Int?, onSelected: (AppTab) -> Unit) {
|
||||
NavigationBar(containerColor = MaterialTheme.colorScheme.surface) {
|
||||
AppTab.entries.forEach { tab ->
|
||||
NavigationBarItem(
|
||||
selected = tab == selected,
|
||||
onClick = { onSelected(tab) },
|
||||
icon = { TabIcon(tab, unreadNotifications) },
|
||||
label = { TabLabel(tab, tab == selected) },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
selectedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SideTabs(selected: AppTab, unreadNotifications: Int?, onSelected: (AppTab) -> Unit) {
|
||||
NavigationRail(containerColor = MaterialTheme.colorScheme.surface) {
|
||||
AppTab.entries.forEach { tab ->
|
||||
NavigationRailItem(
|
||||
selected = tab == selected,
|
||||
onClick = { onSelected(tab) },
|
||||
icon = { TabIcon(tab, unreadNotifications) },
|
||||
label = { TabLabel(tab, tab == selected) },
|
||||
colors = NavigationRailItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
selectedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabIcon(tab: AppTab, unreadNotifications: Int?) {
|
||||
val unread = unreadNotifications?.takeIf { tab == AppTab.NOTIFICATIONS && it > 0 }
|
||||
if (unread == null) {
|
||||
UBIcon(tab.icon, null)
|
||||
} else {
|
||||
BadgedBox(badge = { Badge { Text(if (unread > MAX_BADGE_COUNT) "$MAX_BADGE_COUNT+" else unread.toString()) } }) {
|
||||
UBIcon(tab.icon, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabLabel(tab: AppTab, selected: Boolean) {
|
||||
Text(stringResource(tab.label), fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, maxLines = 1)
|
||||
}
|
||||
|
||||
private const val MAX_BADGE_COUNT = 99
|
||||
@ -1,64 +1,119 @@
|
||||
package com.ubisam.ubgitea.ui.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Sky-blue + white redesign (superseding the earlier "Gitea green" theme -- see git history for
|
||||
// that palette if it's ever needed again). Primary is a deep-enough sky blue (Tailwind's sky-700
|
||||
// family) that white text on it clears WCAG's 4.5:1 threshold for normal-size button labels, while
|
||||
// still reading unmistakably as "sky blue" rather than navy. Background is a near-white with a
|
||||
// faint blue cast rather than stark #FFFFFF, so white cards/surfaces still have visible separation
|
||||
// from the page. Every other role here is a hand-built, standard-shaped Material3 tonal palette
|
||||
// (primary/secondary/tertiary, each with a container + on-* pair, plus neutrals) grown from that
|
||||
// same blue family, so the whole app reads as one deliberate sky-blue theme.
|
||||
// Bright sky blue + white (TASK-050, replacing TASK-045's calmer sky-700 palette). The brand color
|
||||
// is the blue of the UbiSam wordmark itself (BrandSky). White text on that blue only reaches about
|
||||
// 3:1, so it is used for marks that carry no text -- icons, activity bars, selection indicators --
|
||||
// while anything with a label on it (buttons, links) uses the deeper SkyPrimaryLight, which clears
|
||||
// WCAG's 4.5:1. Status colors (open / merged / closed) deliberately stay outside the blue family:
|
||||
// they follow Git hosting convention, and a viewer has to tell them apart from the brand color and
|
||||
// from each other at a glance.
|
||||
|
||||
val BrandSky = Color(0xFF2F98F9)
|
||||
|
||||
// Light theme
|
||||
val SkyPrimaryLight = Color(0xFF0369A1)
|
||||
val SkyPrimaryLight = Color(0xFF0A6FD8)
|
||||
val OnSkyPrimaryLight = Color(0xFFFFFFFF)
|
||||
val SkyPrimaryContainerLight = Color(0xFFBAE6FD)
|
||||
val OnSkyPrimaryContainerLight = Color(0xFF082F49)
|
||||
val SlateSecondaryLight = Color(0xFF4C6B82)
|
||||
val SkyPrimaryContainerLight = Color(0xFFDCEEFF)
|
||||
val OnSkyPrimaryContainerLight = Color(0xFF07509E)
|
||||
val SlateSecondaryLight = Color(0xFF4B5D73)
|
||||
val OnSlateSecondaryLight = Color(0xFFFFFFFF)
|
||||
val SlateSecondaryContainerLight = Color(0xFFD3E4F0)
|
||||
val OnSlateSecondaryContainerLight = Color(0xFF0F2A3A)
|
||||
val CyanTertiaryLight = Color(0xFF006874)
|
||||
val OnCyanTertiaryLight = Color(0xFFFFFFFF)
|
||||
val CyanTertiaryContainerLight = Color(0xFFA6EEF2)
|
||||
val OnCyanTertiaryContainerLight = Color(0xFF001F24)
|
||||
val BackgroundLight = Color(0xFFF7FBFF)
|
||||
val OnBackgroundLight = Color(0xFF10202B)
|
||||
val SurfaceVariantLight = Color(0xFFDCE8EF)
|
||||
val OnSurfaceVariantLight = Color(0xFF41505A)
|
||||
val OutlineLight = Color(0xFF71818C)
|
||||
val OutlineVariantLight = Color(0xFFC0D0D9)
|
||||
val InverseSurfaceLight = Color(0xFF1D333D)
|
||||
val InverseOnSurfaceLight = Color(0xFFECF3F8)
|
||||
val InversePrimaryLight = Color(0xFF8ED0FA)
|
||||
val SlateSecondaryContainerLight = Color(0xFFEAF4FF)
|
||||
val OnSlateSecondaryContainerLight = Color(0xFF0E1B2C)
|
||||
val PurpleTertiaryLight = Color(0xFF7A3FD0)
|
||||
val OnPurpleTertiaryLight = Color(0xFFFFFFFF)
|
||||
val PurpleTertiaryContainerLight = Color(0xFFEEE5FC)
|
||||
val OnPurpleTertiaryContainerLight = Color(0xFF4A1F8F)
|
||||
val ErrorLight = Color(0xFFC62535)
|
||||
val OnErrorLight = Color(0xFFFFFFFF)
|
||||
val ErrorContainerLight = Color(0xFFFDE4E6)
|
||||
val OnErrorContainerLight = Color(0xFF8E1320)
|
||||
val BackgroundLight = Color(0xFFF3F8FE)
|
||||
val SurfaceLight = Color(0xFFFFFFFF)
|
||||
val OnBackgroundLight = Color(0xFF0E1B2C)
|
||||
val SurfaceVariantLight = Color(0xFFEAF4FF)
|
||||
val OnSurfaceVariantLight = Color(0xFF4B5D73)
|
||||
val OutlineLight = Color(0xFF7A8CA3)
|
||||
val OutlineVariantLight = Color(0xFFD9E6F5)
|
||||
val SurfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
val SurfaceContainerLowLight = Color(0xFFF7FBFF)
|
||||
val SurfaceContainerLight = Color(0xFFF0F6FD)
|
||||
val SurfaceContainerHighLight = Color(0xFFEAF2FB)
|
||||
val SurfaceContainerHighestLight = Color(0xFFE3EDF8)
|
||||
val InverseSurfaceLight = Color(0xFF1D2D40)
|
||||
val InverseOnSurfaceLight = Color(0xFFECF3FA)
|
||||
val InversePrimaryLight = Color(0xFF6BB8FF)
|
||||
|
||||
// Dark theme -- tones flip per Material3 convention (lighter primary for legibility on a dark
|
||||
// surface; the light theme's primary becomes the dark theme's inversePrimary, and vice versa).
|
||||
val SkyPrimaryDark = Color(0xFF8ED0FA)
|
||||
val OnSkyPrimaryDark = Color(0xFF00344D)
|
||||
val SkyPrimaryContainerDark = Color(0xFF024C6E)
|
||||
val OnSkyPrimaryContainerDark = Color(0xFFBAE6FD)
|
||||
val SlateSecondaryDark = Color(0xFFB4CADD)
|
||||
val OnSlateSecondaryDark = Color(0xFF1C3444)
|
||||
val SlateSecondaryContainerDark = Color(0xFF34495C)
|
||||
val OnSlateSecondaryContainerDark = Color(0xFFD3E4F0)
|
||||
val CyanTertiaryDark = Color(0xFF83D2DB)
|
||||
val OnCyanTertiaryDark = Color(0xFF00363D)
|
||||
val CyanTertiaryContainerDark = Color(0xFF004F58)
|
||||
val OnCyanTertiaryContainerDark = Color(0xFFA6EEF2)
|
||||
val BackgroundDark = Color(0xFF0B141B)
|
||||
val OnBackgroundDark = Color(0xFFE3EDF3)
|
||||
val SurfaceVariantDark = Color(0xFF41505A)
|
||||
val OnSurfaceVariantDark = Color(0xFFC0D0D9)
|
||||
val OutlineDark = Color(0xFF8B9AA4)
|
||||
val OutlineVariantDark = Color(0xFF41505A)
|
||||
val InverseSurfaceDark = Color(0xFFE3EDF3)
|
||||
val InverseOnSurfaceDark = Color(0xFF1D333D)
|
||||
val InversePrimaryDark = Color(0xFF0369A1)
|
||||
// Dark theme
|
||||
val SkyPrimaryDark = Color(0xFF6BB8FF)
|
||||
val OnSkyPrimaryDark = Color(0xFF04213F)
|
||||
val SkyPrimaryContainerDark = Color(0xFF123A63)
|
||||
val OnSkyPrimaryContainerDark = Color(0xFFBFE0FF)
|
||||
val SlateSecondaryDark = Color(0xFF9FB2C8)
|
||||
val OnSlateSecondaryDark = Color(0xFF0B1B2D)
|
||||
val SlateSecondaryContainerDark = Color(0xFF16293F)
|
||||
val OnSlateSecondaryContainerDark = Color(0xFFE8F0FA)
|
||||
val PurpleTertiaryDark = Color(0xFFBC95F7)
|
||||
val OnPurpleTertiaryDark = Color(0xFF2A1B4A)
|
||||
val PurpleTertiaryContainerDark = Color(0xFF2A1B4A)
|
||||
val OnPurpleTertiaryContainerDark = Color(0xFFE2D3FF)
|
||||
val ErrorDark = Color(0xFFFF8A84)
|
||||
val OnErrorDark = Color(0xFF3B0A0D)
|
||||
val ErrorContainerDark = Color(0xFF471A1D)
|
||||
val OnErrorContainerDark = Color(0xFFFFD0CD)
|
||||
val BackgroundDark = Color(0xFF08121F)
|
||||
val SurfaceDark = Color(0xFF101D2E)
|
||||
val OnBackgroundDark = Color(0xFFE8F0FA)
|
||||
val SurfaceVariantDark = Color(0xFF16293F)
|
||||
val OnSurfaceVariantDark = Color(0xFF9FB2C8)
|
||||
val OutlineDark = Color(0xFF6F849C)
|
||||
val OutlineVariantDark = Color(0xFF22364E)
|
||||
val SurfaceContainerLowestDark = Color(0xFF060E18)
|
||||
val SurfaceContainerLowDark = Color(0xFF0C1827)
|
||||
val SurfaceContainerDark = Color(0xFF101D2E)
|
||||
val SurfaceContainerHighDark = Color(0xFF16293F)
|
||||
val SurfaceContainerHighestDark = Color(0xFF1C3350)
|
||||
val InverseSurfaceDark = Color(0xFFE8F0FA)
|
||||
val InverseOnSurfaceDark = Color(0xFF1D2D40)
|
||||
val InversePrimaryDark = Color(0xFF0A6FD8)
|
||||
|
||||
// UbiSam wordmark colors -- fixed brand colors independent of light/dark theme (a logo should look
|
||||
// the same in both modes, unlike the tonal palette above).
|
||||
val UbiSamLogoGray = Color(0xFF5B6472)
|
||||
val UbiSamLogoBlue = Color(0xFF0EA5E9)
|
||||
/**
|
||||
* Roles Material3's own scheme has no slot for. `success` is the "open" / "added" color, `warning`
|
||||
* the caution color (offline data, truncation); "merged" is the scheme's tertiary (purple) and
|
||||
* "closed" / "removed" its error (red).
|
||||
*/
|
||||
@Immutable
|
||||
data class UBGiteaExtendedColors(
|
||||
val brand: Color,
|
||||
val success: Color,
|
||||
val successContainer: Color,
|
||||
val onSuccessContainer: Color,
|
||||
val warning: Color,
|
||||
val warningContainer: Color,
|
||||
val onWarningContainer: Color,
|
||||
)
|
||||
|
||||
val LightExtendedColors = UBGiteaExtendedColors(
|
||||
brand = BrandSky,
|
||||
success = Color(0xFF1A7F37),
|
||||
successContainer = Color(0xFFDDF4E4),
|
||||
onSuccessContainer = Color(0xFF0F5A26),
|
||||
warning = Color(0xFFB45309),
|
||||
warningContainer = Color(0xFFFDEBD3),
|
||||
onWarningContainer = Color(0xFF7A3A06),
|
||||
)
|
||||
|
||||
val DarkExtendedColors = UBGiteaExtendedColors(
|
||||
brand = BrandSky,
|
||||
success = Color(0xFF56D07A),
|
||||
successContainer = Color(0xFF0F3320),
|
||||
onSuccessContainer = Color(0xFFA6EBBB),
|
||||
warning = Color(0xFFF5B566),
|
||||
warningContainer = Color(0xFF40290B),
|
||||
onWarningContainer = Color(0xFFFFDDB0),
|
||||
)
|
||||
|
||||
val LocalUBGiteaColors = staticCompositionLocalOf { LightExtendedColors }
|
||||
|
||||
@ -5,13 +5,11 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
|
||||
// Sky-blue Material3 color schemes (see Color.kt for the individual values and how they were
|
||||
// derived). Every role not set explicitly here -- error/onError/errorContainer/onErrorContainer,
|
||||
// most notably, kept as Material3's own baseline red -- is left at the corresponding
|
||||
// lightColorScheme()/darkColorScheme() default rather than reinvented, since nothing about this
|
||||
// request was about changing what "error" or "removed" looks like, only about the app's own brand
|
||||
// color.
|
||||
// See Color.kt for the individual values and why each role has the color it has. Surfaces are
|
||||
// white cards on a faintly blue page in light mode, so `surface` and `background` differ.
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = SkyPrimaryLight,
|
||||
onPrimary = OnSkyPrimaryLight,
|
||||
@ -21,18 +19,27 @@ private val LightColorScheme = lightColorScheme(
|
||||
onSecondary = OnSlateSecondaryLight,
|
||||
secondaryContainer = SlateSecondaryContainerLight,
|
||||
onSecondaryContainer = OnSlateSecondaryContainerLight,
|
||||
tertiary = CyanTertiaryLight,
|
||||
onTertiary = OnCyanTertiaryLight,
|
||||
tertiaryContainer = CyanTertiaryContainerLight,
|
||||
onTertiaryContainer = OnCyanTertiaryContainerLight,
|
||||
tertiary = PurpleTertiaryLight,
|
||||
onTertiary = OnPurpleTertiaryLight,
|
||||
tertiaryContainer = PurpleTertiaryContainerLight,
|
||||
onTertiaryContainer = OnPurpleTertiaryContainerLight,
|
||||
error = ErrorLight,
|
||||
onError = OnErrorLight,
|
||||
errorContainer = ErrorContainerLight,
|
||||
onErrorContainer = OnErrorContainerLight,
|
||||
background = BackgroundLight,
|
||||
onBackground = OnBackgroundLight,
|
||||
surface = BackgroundLight,
|
||||
surface = SurfaceLight,
|
||||
onSurface = OnBackgroundLight,
|
||||
surfaceVariant = SurfaceVariantLight,
|
||||
onSurfaceVariant = OnSurfaceVariantLight,
|
||||
outline = OutlineLight,
|
||||
outlineVariant = OutlineVariantLight,
|
||||
surfaceContainerLowest = SurfaceContainerLowestLight,
|
||||
surfaceContainerLow = SurfaceContainerLowLight,
|
||||
surfaceContainer = SurfaceContainerLight,
|
||||
surfaceContainerHigh = SurfaceContainerHighLight,
|
||||
surfaceContainerHighest = SurfaceContainerHighestLight,
|
||||
inverseSurface = InverseSurfaceLight,
|
||||
inverseOnSurface = InverseOnSurfaceLight,
|
||||
inversePrimary = InversePrimaryLight,
|
||||
@ -47,18 +54,27 @@ private val DarkColorScheme = darkColorScheme(
|
||||
onSecondary = OnSlateSecondaryDark,
|
||||
secondaryContainer = SlateSecondaryContainerDark,
|
||||
onSecondaryContainer = OnSlateSecondaryContainerDark,
|
||||
tertiary = CyanTertiaryDark,
|
||||
onTertiary = OnCyanTertiaryDark,
|
||||
tertiaryContainer = CyanTertiaryContainerDark,
|
||||
onTertiaryContainer = OnCyanTertiaryContainerDark,
|
||||
tertiary = PurpleTertiaryDark,
|
||||
onTertiary = OnPurpleTertiaryDark,
|
||||
tertiaryContainer = PurpleTertiaryContainerDark,
|
||||
onTertiaryContainer = OnPurpleTertiaryContainerDark,
|
||||
error = ErrorDark,
|
||||
onError = OnErrorDark,
|
||||
errorContainer = ErrorContainerDark,
|
||||
onErrorContainer = OnErrorContainerDark,
|
||||
background = BackgroundDark,
|
||||
onBackground = OnBackgroundDark,
|
||||
surface = BackgroundDark,
|
||||
surface = SurfaceDark,
|
||||
onSurface = OnBackgroundDark,
|
||||
surfaceVariant = SurfaceVariantDark,
|
||||
onSurfaceVariant = OnSurfaceVariantDark,
|
||||
outline = OutlineDark,
|
||||
outlineVariant = OutlineVariantDark,
|
||||
surfaceContainerLowest = SurfaceContainerLowestDark,
|
||||
surfaceContainerLow = SurfaceContainerLowDark,
|
||||
surfaceContainer = SurfaceContainerDark,
|
||||
surfaceContainerHigh = SurfaceContainerHighDark,
|
||||
surfaceContainerHighest = SurfaceContainerHighestDark,
|
||||
inverseSurface = InverseSurfaceDark,
|
||||
inverseOnSurface = InverseOnSurfaceDark,
|
||||
inversePrimary = InversePrimaryDark,
|
||||
@ -69,8 +85,15 @@ fun UBGiteaTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
|
||||
content = content,
|
||||
)
|
||||
CompositionLocalProvider(LocalUBGiteaColors provides if (darkTheme) DarkExtendedColors else LightExtendedColors) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object UBGiteaTheme {
|
||||
val colors: UBGiteaExtendedColors
|
||||
@Composable @ReadOnlyComposable get() = LocalUBGiteaColors.current
|
||||
}
|
||||
|
||||
BIN
app/src/main/res/drawable-night-nodpi/logo_ubisam.png
Normal file
BIN
app/src/main/res/drawable-night-nodpi/logo_ubisam.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
app/src/main/res/drawable-nodpi/logo_ubisam.png
Normal file
BIN
app/src/main/res/drawable-nodpi/logo_ubisam.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
14
app/src/main/res/drawable/ic_ub_back.xml
Normal file
14
app/src/main/res/drawable/ic_ub_back.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M15,5l-7,7 7,7" />
|
||||
</vector>
|
||||
21
app/src/main/res/drawable/ic_ub_bell.xml
Normal file
21
app/src/main/res/drawable/ic_ub_bell.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M6,9a6,6 0 0 1 12,0c0,6 2.5,7 2.5,7h-17S6,15 6,9z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M10,19.5a2,2 0 0 0 4,0" />
|
||||
</vector>
|
||||
42
app/src/main/res/drawable/ic_ub_branch.xml
Normal file
42
app/src/main/res/drawable/ic_ub_branch.xml
Normal file
@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M3.8,5a2.2,2.2 0 1,0 4.4,0a2.2,2.2 0 1,0 -4.4,0z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M3.8,19a2.2,2.2 0 1,0 4.4,0a2.2,2.2 0 1,0 -4.4,0z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M15.8,7a2.2,2.2 0 1,0 4.4,0a2.2,2.2 0 1,0 -4.4,0z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M6,7.2v9.6" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M18,9.2c0,4.5 -12,2.5 -12,7.6" />
|
||||
</vector>
|
||||
14
app/src/main/res/drawable/ic_ub_check.xml
Normal file
14
app/src/main/res/drawable/ic_ub_check.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M5,12.5l4.5,4.5L19,7.5" />
|
||||
</vector>
|
||||
14
app/src/main/res/drawable/ic_ub_chevron_down.xml
Normal file
14
app/src/main/res/drawable/ic_ub_chevron_down.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M6,9l6,6 6,-6" />
|
||||
</vector>
|
||||
14
app/src/main/res/drawable/ic_ub_chevron_right.xml
Normal file
14
app/src/main/res/drawable/ic_ub_chevron_right.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M9,5l7,7 -7,7" />
|
||||
</vector>
|
||||
14
app/src/main/res/drawable/ic_ub_chevron_up.xml
Normal file
14
app/src/main/res/drawable/ic_ub_chevron_up.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M6,15l6,-6 6,6" />
|
||||
</vector>
|
||||
14
app/src/main/res/drawable/ic_ub_close.xml
Normal file
14
app/src/main/res/drawable/ic_ub_close.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M6,6l12,12M18,6 6,18" />
|
||||
</vector>
|
||||
14
app/src/main/res/drawable/ic_ub_comment.xml
Normal file
14
app/src/main/res/drawable/ic_ub_comment.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M4,5h16v11H9l-5,4z" />
|
||||
</vector>
|
||||
28
app/src/main/res/drawable/ic_ub_commit.xml
Normal file
28
app/src/main/res/drawable/ic_ub_commit.xml
Normal file
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M8.8,12a3.2,3.2 0 1,0 6.4,0a3.2,3.2 0 1,0 -6.4,0z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M3,12h5.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M15.2,12H21" />
|
||||
</vector>
|
||||
21
app/src/main/res/drawable/ic_ub_copy.xml
Normal file
21
app/src/main/res/drawable/ic_ub_copy.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M10,8h8a2,2 0 0 1 2,2v8a2,2 0 0 1 -2,2h-8a2,2 0 0 1 -2,-2v-8a2,2 0 0 1 2,-2z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M16,8V6a2,2 0 0 0 -2,-2H6a2,2 0 0 0 -2,2v8a2,2 0 0 0 2,2h2" />
|
||||
</vector>
|
||||
21
app/src/main/res/drawable/ic_ub_eye.xml
Normal file
21
app/src/main/res/drawable/ic_ub_eye.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M2.5,12S6,5.5 12,5.5 21.5,12 21.5,12 18,18.5 12,18.5 2.5,12 2.5,12z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M9.2,12a2.8,2.8 0 1,0 5.6,0a2.8,2.8 0 1,0 -5.6,0z" />
|
||||
</vector>
|
||||
21
app/src/main/res/drawable/ic_ub_file.xml
Normal file
21
app/src/main/res/drawable/ic_ub_file.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M6,3h8l4,4v14H6z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M14,3v4h4" />
|
||||
</vector>
|
||||
28
app/src/main/res/drawable/ic_ub_files.xml
Normal file
28
app/src/main/res/drawable/ic_ub_files.xml
Normal file
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M8,3h7l4,4v11H8z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M15,3v4h4" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M8,7H5v14h10v-3" />
|
||||
</vector>
|
||||
14
app/src/main/res/drawable/ic_ub_folder.xml
Normal file
14
app/src/main/res/drawable/ic_ub_folder.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M3,6a1,1 0 0 1 1,-1h5l2,2.5h9a1,1 0 0 1 1,1V18a1,1 0 0 1 -1,1H4a1,1 0 0 1 -1,-1z" />
|
||||
</vector>
|
||||
14
app/src/main/res/drawable/ic_ub_home.xml
Normal file
14
app/src/main/res/drawable/ic_ub_home.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M3,10.5 12,3l9,7.5V20a1,1 0 0 1 -1,1h-5v-6H9v6H4a1,1 0 0 1 -1,-1z" />
|
||||
</vector>
|
||||
17
app/src/main/res/drawable/ic_ub_issue.xml
Normal file
17
app/src/main/res/drawable/ic_ub_issue.xml
Normal file
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M3,12a9,9 0 1,0 18,0a9,9 0 1,0 -18,0z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M10.4,12a1.6,1.6 0 1,0 3.2,0a1.6,1.6 0 1,0 -3.2,0z" />
|
||||
</vector>
|
||||
21
app/src/main/res/drawable/ic_ub_list.xml
Normal file
21
app/src/main/res/drawable/ic_ub_list.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M9,6h11M9,12h11M9,18h11" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M4.5,6h0.01M4.5,12h0.01M4.5,18h0.01" />
|
||||
</vector>
|
||||
21
app/src/main/res/drawable/ic_ub_lock.xml
Normal file
21
app/src/main/res/drawable/ic_ub_lock.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M7,11h10a2,2 0 0 1 2,2v5a2,2 0 0 1 -2,2h-10a2,2 0 0 1 -2,-2v-5a2,2 0 0 1 2,-2z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M8,11V8a4,4 0 0 1 8,0v3" />
|
||||
</vector>
|
||||
28
app/src/main/res/drawable/ic_ub_logout.xml
Normal file
28
app/src/main/res/drawable/ic_ub_logout.xml
Normal file
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M10,4H5a1,1 0 0 0 -1,1v14a1,1 0 0 0 1,1h5" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M15,8l4,4 -4,4" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M19,12H9" />
|
||||
</vector>
|
||||
42
app/src/main/res/drawable/ic_ub_merge.xml
Normal file
42
app/src/main/res/drawable/ic_ub_merge.xml
Normal file
@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M3.8,5a2.2,2.2 0 1,0 4.4,0a2.2,2.2 0 1,0 -4.4,0z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M3.8,19a2.2,2.2 0 1,0 4.4,0a2.2,2.2 0 1,0 -4.4,0z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M15.8,13a2.2,2.2 0 1,0 4.4,0a2.2,2.2 0 1,0 -4.4,0z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M6,7.2v9.6" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M6,7.2c0,4 4,5.8 9.8,5.8" />
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/ic_ub_more.xml
Normal file
16
app/src/main/res/drawable/ic_ub_more.xml
Normal file
@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M3.5,12a1.5,1.5 0 1,0 3.0,0a1.5,1.5 0 1,0 -3.0,0z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M10.5,12a1.5,1.5 0 1,0 3.0,0a1.5,1.5 0 1,0 -3.0,0z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M17.5,12a1.5,1.5 0 1,0 3.0,0a1.5,1.5 0 1,0 -3.0,0z" />
|
||||
</vector>
|
||||
49
app/src/main/res/drawable/ic_ub_pull_request.xml
Normal file
49
app/src/main/res/drawable/ic_ub_pull_request.xml
Normal file
@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M3.8,6a2.2,2.2 0 1,0 4.4,0a2.2,2.2 0 1,0 -4.4,0z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M3.8,18a2.2,2.2 0 1,0 4.4,0a2.2,2.2 0 1,0 -4.4,0z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M15.8,18a2.2,2.2 0 1,0 4.4,0a2.2,2.2 0 1,0 -4.4,0z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M6,8.2v7.6" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M18,15.8V9a3,3 0 0 0 -3,-3h-3" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M14,3.5l-2.5,2.5L14,8.5" />
|
||||
</vector>
|
||||
21
app/src/main/res/drawable/ic_ub_refresh.xml
Normal file
21
app/src/main/res/drawable/ic_ub_refresh.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M20,11a8,8 0 1 0 -2.3,6.2" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M20,4v7h-7" />
|
||||
</vector>
|
||||
21
app/src/main/res/drawable/ic_ub_repo.xml
Normal file
21
app/src/main/res/drawable/ic_ub_repo.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M5,19.5V5a2,2 0 0 1 2,-2h12v14H7a2,2 0 0 0 -2,2.5 2,2 0 0 0 2,1.5h12" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M9,7h6" />
|
||||
</vector>
|
||||
21
app/src/main/res/drawable/ic_ub_search.xml
Normal file
21
app/src/main/res/drawable/ic_ub_search.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M4.5,11a6.5,6.5 0 1,0 13.0,0a6.5,6.5 0 1,0 -13.0,0z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M16,16l5,5" />
|
||||
</vector>
|
||||
21
app/src/main/res/drawable/ic_ub_settings.xml
Normal file
21
app/src/main/res/drawable/ic_ub_settings.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M9,12a3,3 0 1,0 6,0a3,3 0 1,0 -6,0z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M12,2.5v3M12,18.5v3M2.5,12h3M18.5,12h3M5.3,5.3l2.1,2.1M16.6,16.6l2.1,2.1M5.3,18.7l2.1,-2.1M16.6,7.4l2.1,-2.1" />
|
||||
</vector>
|
||||
21
app/src/main/res/drawable/ic_ub_split.xml
Normal file
21
app/src/main/res/drawable/ic_ub_split.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M5,4h14a2,2 0 0 1 2,2v12a2,2 0 0 1 -2,2h-14a2,2 0 0 1 -2,-2v-12a2,2 0 0 1 2,-2z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M12,4v16" />
|
||||
</vector>
|
||||
14
app/src/main/res/drawable/ic_ub_star.xml
Normal file
14
app/src/main/res/drawable/ic_ub_star.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M12,3l2.8,5.8 6.2,0.8 -4.5,4.4 1.1,6.3L12,17.3l-5.6,3 1.1,-6.3L3,9.6l6.2,-0.8z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_ub_star_filled.xml
Normal file
10
app/src/main/res/drawable/ic_ub_star_filled.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,3l2.8,5.8 6.2,0.8 -4.5,4.4 1.1,6.3L12,17.3l-5.6,3 1.1,-6.3L3,9.6l6.2,-0.8z" />
|
||||
</vector>
|
||||
17
app/src/main/res/drawable/ic_ub_tag.xml
Normal file
17
app/src/main/res/drawable/ic_ub_tag.xml
Normal file
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M3,12V4h8l10,10 -8,8z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M6.3,8.5a1.2,1.2 0 1,0 2.4,0a1.2,1.2 0 1,0 -2.4,0z" />
|
||||
</vector>
|
||||
35
app/src/main/res/drawable/ic_ub_text_size.xml
Normal file
35
app/src/main/res/drawable/ic_ub_text_size.xml
Normal file
@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M2.5,18 7.5,6l5,12" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M4.3,14h6.4" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M14.5,18l3.2,-7.5L21,18" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M15.6,15.6h4.3" />
|
||||
</vector>
|
||||
21
app/src/main/res/drawable/ic_ub_unfold.xml
Normal file
21
app/src/main/res/drawable/ic_ub_unfold.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M8,9l4,-4 4,4" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M8,15l4,4 4,-4" />
|
||||
</vector>
|
||||
35
app/src/main/res/drawable/ic_ub_wrap.xml
Normal file
35
app/src/main/res/drawable/ic_ub_wrap.xml
Normal file
@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M4,6h16" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M4,12h13a3,3 0 0 1 0,6h-4" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M15,15.5l-2.5,2.5 2.5,2.5" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="1.8"
|
||||
android:pathData="M4,18h4" />
|
||||
</vector>
|
||||
@ -1,4 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">UBGitea</string>
|
||||
<string name="home_read_only_placeholder">저장소, 브랜치와 코드를 안전하게 둘러볼 읽기 전용 앱을 준비하고 있습니다.</string>
|
||||
</resources>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<resources>
|
||||
<string name="branchdetail_tab_files">파일</string>
|
||||
<string name="branchdetail_tab_history">커밋</string>
|
||||
<string name="branchdetail_subtitle">%1$s · %2$s</string>
|
||||
</resources>
|
||||
|
||||
@ -31,4 +31,45 @@
|
||||
<string name="codebrowser_image_decode_failed">이미지를 표시할 수 없습니다.</string>
|
||||
<string name="codebrowser_csv_empty">이 CSV 파일은 비어 있습니다.</string>
|
||||
<string name="codebrowser_pdf_decode_failed">PDF를 표시할 수 없습니다.</string>
|
||||
<string name="codeviewer_location">%1$s · %2$s</string>
|
||||
<string name="codeviewer_line_count">%1$d줄</string>
|
||||
<string name="codeviewer_current_location">현재 위치</string>
|
||||
<string name="codeviewer_tools">코드 보기 도구</string>
|
||||
<string name="codeviewer_tool_search">검색</string>
|
||||
<string name="codeviewer_tool_outline">목차</string>
|
||||
<string name="codeviewer_tool_wrap">줄바꿈</string>
|
||||
<string name="codeviewer_tool_font_size">글자 크기</string>
|
||||
<string name="codeviewer_tool_copy">복사</string>
|
||||
<string name="codeviewer_tool_side_by_side">나란히</string>
|
||||
<string name="codeviewer_tool_sync">짝 맞춰 이동</string>
|
||||
<string name="codeviewer_state_on">켜짐</string>
|
||||
<string name="codeviewer_state_off">꺼짐</string>
|
||||
<string name="codeviewer_font_size_option">%1$dsp</string>
|
||||
<string name="codeviewer_search_hint">파일 안에서 검색</string>
|
||||
<string name="codeviewer_search_close">검색 닫기</string>
|
||||
<string name="codeviewer_search_count">%1$d / %2$d</string>
|
||||
<string name="codeviewer_search_none">결과 없음</string>
|
||||
<string name="codeviewer_search_previous">이전 결과</string>
|
||||
<string name="codeviewer_search_next">다음 결과</string>
|
||||
<string name="codeviewer_outline_title">목차</string>
|
||||
<string name="codeviewer_outline_close">닫기</string>
|
||||
<string name="codeviewer_outline_hint">줄 번호</string>
|
||||
<string name="codeviewer_outline_go">이동</string>
|
||||
<string name="codeviewer_outline_empty">이 파일에서는 목차를 만들 수 없습니다. 줄 번호로 이동할 수 있습니다.</string>
|
||||
<string name="codeviewer_outline_line">줄 %1$d</string>
|
||||
<string name="codeviewer_outline_line_out_of_range">1~%1$d 사이의 줄 번호를 입력하세요.</string>
|
||||
<string name="codeviewer_symbol_namespace">네임스페이스</string>
|
||||
<string name="codeviewer_symbol_type">타입</string>
|
||||
<string name="codeviewer_symbol_method">메서드</string>
|
||||
<string name="codeviewer_symbol_field">필드</string>
|
||||
<string name="codeviewer_files_title">파일</string>
|
||||
<string name="codeviewer_pair_label">짝 파일</string>
|
||||
<string name="codeviewer_pair_header">선언</string>
|
||||
<string name="codeviewer_pair_source">구현</string>
|
||||
<string name="codeviewer_jump_to_header">선언 보기 · %1$s</string>
|
||||
<string name="codeviewer_jump_to_source">구현 보기 · %1$s</string>
|
||||
<string name="codeviewer_counterpart_loading">짝 파일을 불러오는 중입니다.</string>
|
||||
<string name="codeviewer_counterpart_failed">짝 파일을 불러오지 못했습니다.</string>
|
||||
<string name="codeviewer_counterpart_not_text">짝 파일은 텍스트로 표시할 수 없습니다.</string>
|
||||
<string name="codeviewer_more">더 보기</string>
|
||||
</resources>
|
||||
|
||||
@ -27,4 +27,21 @@
|
||||
<string name="history_conflict">저장소가 비어 있거나 비교할 수 없는 상태입니다.</string>
|
||||
<string name="history_unprocessable">요청을 처리할 수 없습니다.</string>
|
||||
<string name="history_server_error">Gitea 서버에서 오류가 발생했습니다.</string>
|
||||
<string name="history_files_changed">변경된 파일 %1$d개</string>
|
||||
<string name="history_patch_title">전체 변경 내용</string>
|
||||
<string name="history_diff_file_position">파일 %1$d / %2$d</string>
|
||||
<string name="history_diff_added_count">+%1$d</string>
|
||||
<string name="history_diff_removed_count">−%1$d</string>
|
||||
<string name="history_diff_added_description">추가 %1$d줄</string>
|
||||
<string name="history_diff_removed_description">삭제 %1$d줄</string>
|
||||
<string name="history_diff_previous_file">이전 파일</string>
|
||||
<string name="history_diff_next_file">다음 파일</string>
|
||||
<string name="history_diff_previous_change">이전 변경</string>
|
||||
<string name="history_diff_next_change">다음 변경</string>
|
||||
<string name="history_diff_file_list">파일 목록</string>
|
||||
<string name="history_diff_tools">변경 내용 도구</string>
|
||||
<string name="history_diff_line_added">추가된 줄</string>
|
||||
<string name="history_diff_line_removed">삭제된 줄</string>
|
||||
<string name="history_diff_hunk">변경 구간</string>
|
||||
<string name="history_loading">커밋을 불러오는 중입니다.</string>
|
||||
</resources>
|
||||
|
||||
92
app/src/main/res/values/strings_home.xml
Normal file
92
app/src/main/res/values/strings_home.xml
Normal file
@ -0,0 +1,92 @@
|
||||
<resources>
|
||||
<string name="home_title">팀 활동</string>
|
||||
<string name="home_subtitle">%1$s · 오늘 기준</string>
|
||||
<string name="home_loading">팀 활동을 불러오는 중입니다.</string>
|
||||
<string name="home_source_mine">내 피드</string>
|
||||
<string name="home_source_label">활동 범위</string>
|
||||
<string name="home_stat_pushes">오늘 Push</string>
|
||||
<string name="home_stat_open_pull_requests">열린 PR</string>
|
||||
<string name="home_stat_issues">새 이슈</string>
|
||||
<string name="home_stat_unknown">–</string>
|
||||
<string name="home_stat_at_least">%1$d+</string>
|
||||
<string name="home_stat_description">%1$s %2$s</string>
|
||||
<string name="home_attention_title">내 확인이 필요한 항목 %1$d건</string>
|
||||
<string name="home_attention_none">내 확인이 필요한 항목이 없습니다</string>
|
||||
<string name="home_attention_detail">리뷰 요청 %1$d · 나에게 할당된 이슈 %2$d</string>
|
||||
<string name="home_active_repositories">활동 중인 저장소</string>
|
||||
<string name="home_active_repositories_all">전체</string>
|
||||
<string name="home_active_repository_footer">%1$s · %2$s</string>
|
||||
<string name="home_activity_bars_description">최근 7일 활동 %1$d건</string>
|
||||
<string name="home_timeline">타임라인</string>
|
||||
<string name="home_filter_all">전체</string>
|
||||
<string name="home_filter_push">Push</string>
|
||||
<string name="home_filter_pull_request">PR</string>
|
||||
<string name="home_filter_issue">이슈</string>
|
||||
<string name="home_timeline_empty">표시할 활동이 없습니다.</string>
|
||||
<string name="home_timeline_filter_empty">불러온 활동 중에는 해당하는 항목이 없습니다.</string>
|
||||
<string name="home_load_more">이전 활동 더 보기</string>
|
||||
<string name="home_load_more_loading">불러오는 중…</string>
|
||||
<string name="home_load_more_failed">더 불러오지 못했습니다. 다시 시도</string>
|
||||
<string name="home_refresh">새로고침</string>
|
||||
<string name="home_retry">다시 시도</string>
|
||||
<string name="home_sign_in_again">다시 로그인</string>
|
||||
<string name="home_more_commits">외 %1$d개 커밋</string>
|
||||
<string name="home_commit_count">커밋 %1$d개</string>
|
||||
<string name="home_subject_number">#%1$d %2$s</string>
|
||||
<string name="home_subject_number_only">#%1$d</string>
|
||||
<string name="home_kind_push">Push</string>
|
||||
<string name="home_kind_tag_pushed">태그 생성</string>
|
||||
<string name="home_kind_branch_deleted">브랜치 삭제</string>
|
||||
<string name="home_kind_tag_deleted">태그 삭제</string>
|
||||
<string name="home_kind_pull_request_opened">PR 열림</string>
|
||||
<string name="home_kind_pull_request_merged">PR 병합</string>
|
||||
<string name="home_kind_pull_request_closed">PR 닫힘</string>
|
||||
<string name="home_kind_pull_request_reopened">PR 다시 열림</string>
|
||||
<string name="home_kind_pull_request_approved">PR 승인</string>
|
||||
<string name="home_kind_pull_request_changes_requested">변경 요청</string>
|
||||
<string name="home_kind_pull_request_commented">PR 댓글</string>
|
||||
<string name="home_kind_issue_opened">이슈 열림</string>
|
||||
<string name="home_kind_issue_closed">이슈 닫힘</string>
|
||||
<string name="home_kind_issue_reopened">이슈 다시 열림</string>
|
||||
<string name="home_kind_issue_commented">이슈 댓글</string>
|
||||
<string name="home_kind_release_published">릴리스</string>
|
||||
<string name="home_kind_repository_created">저장소 생성</string>
|
||||
<string name="home_kind_other">활동</string>
|
||||
<string name="home_offline">서버에 연결할 수 없습니다. 네트워크를 확인해 주세요.</string>
|
||||
<string name="home_auth_expired">로그인이 만료되었습니다. 다시 로그인해 주세요.</string>
|
||||
<string name="home_permission_denied">활동을 볼 권한이 없습니다.</string>
|
||||
<string name="home_server_error">Gitea 서버에서 오류가 발생했습니다.</string>
|
||||
|
||||
<string name="work_title">작업</string>
|
||||
<string name="work_subtitle">모든 저장소의 이슈와 PR</string>
|
||||
<string name="work_loading">작업을 불러오는 중입니다.</string>
|
||||
<string name="work_scope_everyone">팀 전체</string>
|
||||
<string name="work_scope_review_requested">리뷰 요청</string>
|
||||
<string name="work_scope_assigned">나에게 할당</string>
|
||||
<string name="work_scope_created">내가 작성</string>
|
||||
<string name="work_scope_label">보기 범위</string>
|
||||
<string name="work_state_open">열림</string>
|
||||
<string name="work_state_closed">닫힘</string>
|
||||
<string name="work_type_all">전체</string>
|
||||
<string name="work_type_issues">이슈</string>
|
||||
<string name="work_type_pull_requests">PR</string>
|
||||
<string name="work_total">%1$d건</string>
|
||||
<string name="work_empty">조건에 맞는 항목이 없습니다.</string>
|
||||
<string name="work_reference">%1$s #%2$d</string>
|
||||
<string name="work_meta">%1$s · %2$s</string>
|
||||
<string name="work_comments_description">댓글 %1$d개</string>
|
||||
<string name="work_badge_issue_open">이슈 열림</string>
|
||||
<string name="work_badge_issue_closed">이슈 닫힘</string>
|
||||
<string name="work_badge_pull_request_open">PR 열림</string>
|
||||
<string name="work_badge_pull_request_closed">PR 닫힘</string>
|
||||
<string name="work_badge_pull_request_merged">PR 병합</string>
|
||||
<string name="work_previous_page">이전 페이지</string>
|
||||
<string name="work_next_page">다음 페이지</string>
|
||||
<string name="work_page_number">%1$d페이지</string>
|
||||
<string name="work_retry">다시 시도</string>
|
||||
<string name="work_sign_in_again">다시 로그인</string>
|
||||
<string name="work_offline">서버에 연결할 수 없습니다. 네트워크를 확인해 주세요.</string>
|
||||
<string name="work_auth_expired">로그인이 만료되었습니다. 다시 로그인해 주세요.</string>
|
||||
<string name="work_permission_denied">이슈와 PR을 볼 권한이 없습니다.</string>
|
||||
<string name="work_server_error">Gitea 서버에서 오류가 발생했습니다.</string>
|
||||
</resources>
|
||||
@ -3,19 +3,14 @@
|
||||
<string name="notifications_title">알림</string>
|
||||
<string name="notifications_filter_unread">읽지 않음</string>
|
||||
<string name="notifications_filter_all">전체</string>
|
||||
<string name="notifications_unread_label">[읽지 않음]</string>
|
||||
<string name="notifications_unread_dot_description">읽지 않음</string>
|
||||
<string name="notifications_empty">알림이 없습니다.</string>
|
||||
<string name="notifications_retry">다시 시도</string>
|
||||
<string name="notifications_sign_in_again">다시 로그인</string>
|
||||
<string name="notifications_previous_page">이전 페이지</string>
|
||||
<string name="notifications_next_page">다음 페이지</string>
|
||||
<string name="notifications_page_number">%1$d페이지</string>
|
||||
<string name="notifications_repository">%1$s/%2$s</string>
|
||||
<string name="notifications_subject_issue">이슈</string>
|
||||
<string name="notifications_subject_pull_request">PR</string>
|
||||
<string name="notifications_subject_commit">커밋</string>
|
||||
<string name="notifications_subject_repository">저장소</string>
|
||||
<string name="notifications_subject_other">기타</string>
|
||||
<string name="notifications_reference">%1$s #%2$d</string>
|
||||
<string name="notifications_state_open">열림</string>
|
||||
<string name="notifications_state_closed">닫힘</string>
|
||||
<string name="notifications_state_merged">병합됨</string>
|
||||
|
||||
@ -18,24 +18,17 @@
|
||||
<string name="repositories_logout">로그아웃</string>
|
||||
<string name="repositories_private">비공개</string>
|
||||
<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">로딩 중</string>
|
||||
<string name="repositories_branches_title">브랜치</string>
|
||||
<string name="repositories_branches_empty">브랜치가 없습니다.</string>
|
||||
<string name="repositories_tags_title">태그</string>
|
||||
<string name="repositories_tags_empty">태그가 없습니다.</string>
|
||||
<string name="repositories_protected_branch">보호된 브랜치</string>
|
||||
<string name="repositories_commit_at">커밋 시각: %1$s</string>
|
||||
<string name="repositories_back">저장소 목록</string>
|
||||
<string name="repositories_refresh">새로고침</string>
|
||||
<string name="repositories_previous_page">이전 페이지</string>
|
||||
<string name="repositories_next_page">다음 페이지</string>
|
||||
<string name="repositories_page_number">%1$d페이지</string>
|
||||
<string name="repositories_last_pushed_branch_loading">마지막 push 브랜치 확인 중…</string>
|
||||
<string name="repositories_last_pushed_branch">마지막 push: %1$s</string>
|
||||
<string name="repositories_last_pushed_branch_none">브랜치가 없습니다.</string>
|
||||
<string name="repositories_last_pushed_branch_failed">마지막 push 브랜치를 불러오지 못했습니다.</string>
|
||||
<string name="repositories_tab_all">전체 저장소</string>
|
||||
@ -48,4 +41,5 @@
|
||||
<string name="repositories_favorited">즐겨찾기됨</string>
|
||||
<string name="repositories_work_items">PR/이슈</string>
|
||||
<string name="repositories_organization_all">전체</string>
|
||||
<string name="repositories_list_title">저장소</string>
|
||||
</resources>
|
||||
|
||||
@ -4,4 +4,12 @@
|
||||
<string name="settings_reactive_auth_retry_description">인증이 예기치 않게 만료되면, 재로그인 화면을 띄우기 전에 한 번 더 조용히 갱신을 시도합니다.</string>
|
||||
<string name="settings_favorite_push_notifications_title">즐겨찾기 저장소 Push 알림</string>
|
||||
<string name="settings_favorite_push_notifications_description">즐겨찾기한 저장소에 새로운 push가 있으면 알려드립니다.</string>
|
||||
<string name="settings_section_account">계정</string>
|
||||
<string name="settings_section_code_view">코드 보기</string>
|
||||
<string name="settings_section_actions">동작</string>
|
||||
<string name="settings_account_unknown">알 수 없는 계정</string>
|
||||
<string name="settings_code_word_wrap_title">자동 줄바꿈</string>
|
||||
<string name="settings_code_word_wrap_description">코드 보기 화면에서 긴 줄을 자동으로 줄바꿈합니다.</string>
|
||||
<string name="settings_code_font_size_title">글자 크기</string>
|
||||
<string name="settings_code_font_size_value">%1$dsp</string>
|
||||
</resources>
|
||||
|
||||
15
app/src/main/res/values/strings_shell.xml
Normal file
15
app/src/main/res/values/strings_shell.xml
Normal file
@ -0,0 +1,15 @@
|
||||
<resources>
|
||||
<string name="shell_tab_home">홈</string>
|
||||
<string name="shell_tab_repositories">저장소</string>
|
||||
<string name="shell_tab_work">작업</string>
|
||||
<string name="shell_tab_notifications">알림</string>
|
||||
<string name="shell_back">뒤로</string>
|
||||
<string name="shell_logo_description">UbiSam</string>
|
||||
<string name="shell_profile_menu">프로필 및 설정</string>
|
||||
<string name="shell_account_separator">" · %1$s"</string>
|
||||
<string name="time_just_now">방금</string>
|
||||
<string name="time_minutes_ago">%1$d분 전</string>
|
||||
<string name="time_hours_ago">%1$d시간 전</string>
|
||||
<string name="time_yesterday">어제</string>
|
||||
<string name="time_days_ago">%1$d일 전</string>
|
||||
</resources>
|
||||
@ -10,14 +10,13 @@
|
||||
<string name="workitems_page_jump_suffix">/ %1$d ]</string>
|
||||
<string name="workitems_page_jump_go">이동</string>
|
||||
<string name="workitems_page_jump_out_of_range">1~%1$d 사이의 페이지를 입력하세요.</string>
|
||||
<string name="workitems_by_author">%1$s · 댓글 %2$d개</string>
|
||||
<string name="workitems_author">작성자: %1$s</string>
|
||||
<string name="workitems_author_unavailable">(알 수 없는 사용자)</string>
|
||||
<string name="workitems_assignees">담당자: %1$s</string>
|
||||
<string name="workitems_no_comments">댓글이 없습니다.</string>
|
||||
<string name="workitems_no_reviews">리뷰가 없습니다.</string>
|
||||
<string name="workitems_reviews_title">리뷰</string>
|
||||
<string name="workitems_pr_refs">%1$s ← %2$s</string>
|
||||
<string name="workitems_comments_title">댓글</string>
|
||||
<string name="workitems_number">#%1$d</string>
|
||||
<string name="workitems_mergeable">병합 가능</string>
|
||||
<string name="workitems_not_mergeable">병합 불가</string>
|
||||
<string name="workitems_issue_open">열림</string>
|
||||
|
||||
@ -0,0 +1,208 @@
|
||||
package com.ubisam.ubgitea.data.activity
|
||||
|
||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||
import com.ubisam.ubgitea.core.network.HttpResponse
|
||||
import com.ubisam.ubgitea.core.network.HttpTransport
|
||||
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityKind
|
||||
import java.time.LocalDate
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class GiteaActivityApiTest {
|
||||
@Test
|
||||
fun `a push activity maps commits, short id, first-line message and count`() = runTest {
|
||||
val transport = QueueTransport(
|
||||
response(
|
||||
"""[{"id":1,"op_type":"commit_repo","act_user":{"login":"octocat","full_name":"Octo Cat"},
|
||||
"repo":{"name":"repo","owner":{"login":"team"}},"ref_name":"refs/heads/main",
|
||||
"content":"{\"Commits\":[{\"Sha1\":\"abcdef1234\",\"Message\":\"첫 줄\n둘째 줄\"}],\"Len\":3}",
|
||||
"created":"2026-09-28T10:00:00Z"}]""",
|
||||
),
|
||||
)
|
||||
|
||||
val item = api(transport).listUserFeed("octocat", null, 1, 20).items.single()
|
||||
|
||||
assertEquals(ActivityKind.PUSH, item.kind)
|
||||
assertEquals("main", item.refName)
|
||||
assertEquals(1, item.commits.size)
|
||||
assertEquals("abcdef1", item.commits.single().shortId)
|
||||
assertEquals("첫 줄", item.commits.single().message)
|
||||
assertEquals(3, item.commitCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an issue or pull request activity splits the number and text on the pipe`() = runTest {
|
||||
val transport = QueueTransport(
|
||||
response(
|
||||
"""[{"id":1,"op_type":"create_issue","act_user":{"login":"u"},
|
||||
"repo":{"name":"r","owner":{"login":"o"}},"content":"42|제목",
|
||||
"created":"2026-09-28T10:00:00Z"}]""",
|
||||
),
|
||||
)
|
||||
|
||||
val item = api(transport).listUserFeed("octocat", null, 1, 20).items.single()
|
||||
|
||||
assertEquals(42L, item.subjectNumber)
|
||||
assertEquals("제목", item.subjectText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content without a pipe has no subject number`() = runTest {
|
||||
val transport = QueueTransport(
|
||||
response(
|
||||
"""[{"id":1,"op_type":"create_issue","act_user":{"login":"u"},
|
||||
"repo":{"name":"r","owner":{"login":"o"}},"content":"just a title",
|
||||
"created":"2026-09-28T10:00:00Z"}]""",
|
||||
),
|
||||
)
|
||||
|
||||
val item = api(transport).listUserFeed("octocat", null, 1, 20).items.single()
|
||||
|
||||
assertNull(item.subjectNumber)
|
||||
assertEquals("just a title", item.subjectText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed push content does not throw and leaves commits empty`() = runTest {
|
||||
val transport = QueueTransport(
|
||||
response(
|
||||
"""[{"id":1,"op_type":"commit_repo","act_user":{"login":"u"},
|
||||
"repo":{"name":"r","owner":{"login":"o"}},"content":"{ this is not json",
|
||||
"created":"2026-09-28T10:00:00Z"}]""",
|
||||
),
|
||||
)
|
||||
|
||||
val item = api(transport).listUserFeed("octocat", null, 1, 20).items.single()
|
||||
|
||||
assertTrue(item.commits.isEmpty())
|
||||
assertEquals(0, item.commitCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `starring watching and mirror sync activities are dropped, unknown op_type becomes OTHER`() = runTest {
|
||||
val transport = QueueTransport(
|
||||
response(
|
||||
"""[
|
||||
{"id":1,"op_type":"star_repo","act_user":{"login":"u"},"repo":{"name":"r","owner":{"login":"o"}},"created":"2026-09-28T10:00:00Z"},
|
||||
{"id":2,"op_type":"watch_repo","act_user":{"login":"u"},"repo":{"name":"r","owner":{"login":"o"}},"created":"2026-09-28T10:00:00Z"},
|
||||
{"id":3,"op_type":"mirror_sync_push","act_user":{"login":"u"},"repo":{"name":"r","owner":{"login":"o"}},"created":"2026-09-28T10:00:00Z"},
|
||||
{"id":4,"op_type":"some_future_thing","act_user":{"login":"u"},"repo":{"name":"r","owner":{"login":"o"}},"created":"2026-09-28T10:00:00Z"}
|
||||
]""",
|
||||
),
|
||||
)
|
||||
|
||||
val items = api(transport).listUserFeed("octocat", null, 1, 20).items
|
||||
|
||||
assertEquals(listOf(4L), items.map { it.id })
|
||||
assertEquals(ActivityKind.OTHER, items.single().kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next page is based on how many rows were received, not how many survived filtering`() = runTest {
|
||||
val entries = (1..50).joinToString(",") { id ->
|
||||
"""{"id":$id,"op_type":"star_repo","act_user":{"login":"u"},"repo":{"name":"r","owner":{"login":"o"}},"created":"2026-09-28T10:00:00Z"}"""
|
||||
}
|
||||
val transport = QueueTransport(response("[$entries]"))
|
||||
|
||||
val page = api(transport).listUserFeed("octocat", null, 1, 50)
|
||||
|
||||
assertTrue(page.items.isEmpty())
|
||||
assertEquals(2, page.nextPage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `created accepts both an explicit offset and a Z suffix as the same instant`() = runTest {
|
||||
val transport = QueueTransport(
|
||||
response(
|
||||
"""[
|
||||
{"id":1,"op_type":"create_issue","act_user":{"login":"u"},"repo":{"name":"r","owner":{"login":"o"}},"content":"1|a","created":"2026-09-28T10:00:00+09:00"},
|
||||
{"id":2,"op_type":"create_issue","act_user":{"login":"u"},"repo":{"name":"r","owner":{"login":"o"}},"content":"2|b","created":"2026-09-28T01:00:00Z"}
|
||||
]""",
|
||||
),
|
||||
)
|
||||
|
||||
val items = api(transport).listUserFeed("octocat", null, 1, 20).items
|
||||
|
||||
assertEquals(2, items.size)
|
||||
assertEquals(items[0].createdAtEpochMillis, items[1].createdAtEpochMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a date argument adds a date query parameter, its absence omits it`() = runTest {
|
||||
val withDate = QueueTransport(response("[]"))
|
||||
api(withDate).listUserFeed("octocat", LocalDate.of(2026, 9, 28), 1, 20)
|
||||
assertEquals("2026-09-28", withDate.urls.single().toHttpUrl().queryParameter("date"))
|
||||
|
||||
val withoutDate = QueueTransport(response("[]"))
|
||||
api(withoutDate).listUserFeed("octocat", null, 1, 20)
|
||||
assertNull(withoutDate.urls.single().toHttpUrl().queryParameter("date"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the user feed and organization feed use their own url paths`() = runTest {
|
||||
val userTransport = QueueTransport(response("[]"))
|
||||
api(userTransport).listUserFeed("octocat", null, 1, 20)
|
||||
assertTrue(userTransport.urls.single().toHttpUrl().encodedPath.endsWith("/api/v1/users/octocat/activities/feeds"))
|
||||
|
||||
val orgTransport = QueueTransport(response("[]"))
|
||||
api(orgTransport).listOrganizationFeed("myorg", null, 1, 20)
|
||||
assertTrue(orgTransport.urls.single().toHttpUrl().encodedPath.endsWith("/api/v1/orgs/myorg/activities/feeds"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null body maps to an empty list`() = runTest {
|
||||
val transport = QueueTransport(response("null"))
|
||||
|
||||
val page = api(transport).listUserFeed("octocat", null, 1, 20)
|
||||
|
||||
assertTrue(page.items.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `401 maps to authentication expired`() = runTest {
|
||||
val transport = QueueTransport(HttpResponse(401, "", emptyMap()))
|
||||
try {
|
||||
api(transport).listUserFeed("octocat", null, 1, 20)
|
||||
fail("Expected AuthenticationExpired")
|
||||
} catch (_: ApiFailure.AuthenticationExpired) {
|
||||
// Expected.
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank token fails closed with authentication expired`() = runTest {
|
||||
val api = GiteaActivityApi(UBGiteaOAuthDefaults.config, QueueTransport(response("[]")), BearerTokenProvider { "" })
|
||||
try {
|
||||
api.listUserFeed("octocat", null, 1, 20)
|
||||
fail("Expected AuthenticationExpired")
|
||||
} catch (_: ApiFailure.AuthenticationExpired) {
|
||||
// Expected.
|
||||
}
|
||||
}
|
||||
|
||||
private fun api(transport: HttpTransport) =
|
||||
GiteaActivityApi(UBGiteaOAuthDefaults.config, transport, BearerTokenProvider { "access" })
|
||||
|
||||
private fun response(body: String, headers: Map<String, String> = emptyMap()) = HttpResponse(200, body, headers)
|
||||
|
||||
private class QueueTransport(vararg responses: HttpResponse) : HttpTransport {
|
||||
private val queue = ArrayDeque(responses.toList())
|
||||
val urls = mutableListOf<String>()
|
||||
|
||||
override suspend fun postForm(url: String, fields: Map<String, String>) = error("Unexpected POST")
|
||||
override suspend fun get(url: String, bearerToken: String): HttpResponse {
|
||||
urls += url
|
||||
assertEquals("access", bearerToken)
|
||||
return queue.removeFirst()
|
||||
}
|
||||
override suspend fun put(url: String, bearerToken: String) = error("Unexpected PUT")
|
||||
override suspend fun delete(url: String, bearerToken: String) = error("Unexpected DELETE")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,194 @@
|
||||
package com.ubisam.ubgitea.data.workitems
|
||||
|
||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||
import com.ubisam.ubgitea.core.network.HttpResponse
|
||||
import com.ubisam.ubgitea.core.network.HttpTransport
|
||||
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkItemAuthor
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkScope
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkStateFilter
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkTypeFilter
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class GiteaWorkSearchApiTest {
|
||||
@Test
|
||||
fun `review requested scope sets review_requested, leaving the other scope flags out`() = runTest {
|
||||
val transport = QueueTransport(response("[]"))
|
||||
api(transport).search(WorkSearchQuery(scope = WorkScope.REVIEW_REQUESTED), 1, 20)
|
||||
val url = transport.urls.single().toHttpUrl()
|
||||
assertEquals("true", url.queryParameter("review_requested"))
|
||||
assertNull(url.queryParameter("assigned"))
|
||||
assertNull(url.queryParameter("created"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `assigned to me scope sets the assigned flag`() = runTest {
|
||||
val transport = QueueTransport(response("[]"))
|
||||
api(transport).search(WorkSearchQuery(scope = WorkScope.ASSIGNED_TO_ME), 1, 20)
|
||||
assertEquals("true", transport.urls.single().toHttpUrl().queryParameter("assigned"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `created by me scope sets the created flag`() = runTest {
|
||||
val transport = QueueTransport(response("[]"))
|
||||
api(transport).search(WorkSearchQuery(scope = WorkScope.CREATED_BY_ME), 1, 20)
|
||||
assertEquals("true", transport.urls.single().toHttpUrl().queryParameter("created"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everyone scope sets none of the scope flags`() = runTest {
|
||||
val transport = QueueTransport(response("[]"))
|
||||
api(transport).search(WorkSearchQuery(scope = WorkScope.EVERYONE), 1, 20)
|
||||
val url = transport.urls.single().toHttpUrl()
|
||||
assertNull(url.queryParameter("review_requested"))
|
||||
assertNull(url.queryParameter("assigned"))
|
||||
assertNull(url.queryParameter("created"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `issue and pull request type filters set the type query parameter`() = runTest {
|
||||
val issuesTransport = QueueTransport(response("[]"))
|
||||
api(issuesTransport).search(WorkSearchQuery(type = WorkTypeFilter.ISSUES), 1, 20)
|
||||
assertEquals("issues", issuesTransport.urls.single().toHttpUrl().queryParameter("type"))
|
||||
|
||||
val pullsTransport = QueueTransport(response("[]"))
|
||||
api(pullsTransport).search(WorkSearchQuery(type = WorkTypeFilter.PULL_REQUESTS), 1, 20)
|
||||
assertEquals("pulls", pullsTransport.urls.single().toHttpUrl().queryParameter("type"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ALL type filter omits the type query parameter`() = runTest {
|
||||
val transport = QueueTransport(response("[]"))
|
||||
api(transport).search(WorkSearchQuery(type = WorkTypeFilter.ALL), 1, 20)
|
||||
assertNull(transport.urls.single().toHttpUrl().queryParameter("type"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state maps to open or closed`() = runTest {
|
||||
val openTransport = QueueTransport(response("[]"))
|
||||
api(openTransport).search(WorkSearchQuery(state = WorkStateFilter.OPEN), 1, 20)
|
||||
assertEquals("open", openTransport.urls.single().toHttpUrl().queryParameter("state"))
|
||||
|
||||
val closedTransport = QueueTransport(response("[]"))
|
||||
api(closedTransport).search(WorkSearchQuery(state = WorkStateFilter.CLOSED), 1, 20)
|
||||
assertEquals("closed", closedTransport.urls.single().toHttpUrl().queryParameter("state"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `owner is included when present and omitted when null`() = runTest {
|
||||
val withOwner = QueueTransport(response("[]"))
|
||||
api(withOwner).search(WorkSearchQuery(owner = "team"), 1, 20)
|
||||
assertEquals("team", withOwner.urls.single().toHttpUrl().queryParameter("owner"))
|
||||
|
||||
val withoutOwner = QueueTransport(response("[]"))
|
||||
api(withoutOwner).search(WorkSearchQuery(owner = null), 1, 20)
|
||||
assertNull(withoutOwner.urls.single().toHttpUrl().queryParameter("owner"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `maps a repository whose owner is a plain string, and pull request flags`() = runTest {
|
||||
val transport = QueueTransport(
|
||||
response(
|
||||
"""[{"number":7,"title":"Fix bug","state":"open","user":{"login":"octo","full_name":"Octo Cat"},
|
||||
"comments":2,"repository":{"name":"repo","owner":"team"},"updated_at":"2026-09-28T10:00:00Z",
|
||||
"pull_request":{"merged":true}}]""",
|
||||
),
|
||||
)
|
||||
|
||||
val hit = api(transport).search(WorkSearchQuery(), 1, 20).items.single()
|
||||
|
||||
assertEquals(RepositoryCoordinates.of("team", "repo"), hit.repository)
|
||||
assertTrue(hit.isPullRequest)
|
||||
assertTrue(hit.isMerged)
|
||||
assertEquals(7L, hit.number)
|
||||
assertEquals(2, hit.commentCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an issue has no pull request object and is not merged`() = runTest {
|
||||
val transport = QueueTransport(
|
||||
response(
|
||||
"""[{"number":1,"title":"Bug","state":"closed","user":null,
|
||||
"comments":0,"repository":{"name":"repo","owner":"team"},"updated_at":"2026-09-28T10:00:00Z"}]""",
|
||||
),
|
||||
)
|
||||
|
||||
val hit = api(transport).search(WorkSearchQuery(), 1, 20).items.single()
|
||||
|
||||
assertFalse(hit.isPullRequest)
|
||||
assertFalse(hit.isMerged)
|
||||
assertFalse(hit.isOpen)
|
||||
assertEquals(WorkItemAuthor.Unavailable, hit.author)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognized state fails closed with InvalidResponse`() = runTest {
|
||||
val transport = QueueTransport(
|
||||
response(
|
||||
"""[{"number":1,"title":"Bug","state":"draft","user":null,
|
||||
"comments":0,"repository":{"name":"repo","owner":"team"},"updated_at":"2026-09-28T10:00:00Z"}]""",
|
||||
),
|
||||
)
|
||||
try {
|
||||
api(transport).search(WorkSearchQuery(), 1, 20)
|
||||
fail("Expected InvalidResponse")
|
||||
} catch (_: ApiFailure.InvalidResponse) {
|
||||
// Expected.
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `X-Total-Count header becomes the page total count`() = runTest {
|
||||
val transport = QueueTransport(response("[]", headers = mapOf("X-Total-Count" to "42")))
|
||||
|
||||
val page = api(transport).search(WorkSearchQuery(), 1, 20)
|
||||
|
||||
assertEquals(42, page.totalCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `count requests a single item page and returns the header total`() = runTest {
|
||||
val transport = QueueTransport(response("[]", headers = mapOf("X-Total-Count" to "17")))
|
||||
|
||||
val total = api(transport).count(WorkSearchQuery())
|
||||
|
||||
assertEquals(17, total)
|
||||
assertEquals("1", transport.urls.single().toHttpUrl().queryParameter("limit"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `count returns null when the server sends no total count header`() = runTest {
|
||||
val transport = QueueTransport(response("[]"))
|
||||
|
||||
assertNull(api(transport).count(WorkSearchQuery()))
|
||||
}
|
||||
|
||||
private fun api(transport: HttpTransport) =
|
||||
GiteaWorkSearchApi(UBGiteaOAuthDefaults.config, transport, BearerTokenProvider { "access" })
|
||||
|
||||
private fun response(body: String, headers: Map<String, String> = emptyMap()) = HttpResponse(200, body, headers)
|
||||
|
||||
private class QueueTransport(vararg responses: HttpResponse) : HttpTransport {
|
||||
private val queue = ArrayDeque(responses.toList())
|
||||
val urls = mutableListOf<String>()
|
||||
|
||||
override suspend fun postForm(url: String, fields: Map<String, String>) = error("Unexpected POST")
|
||||
override suspend fun get(url: String, bearerToken: String): HttpResponse {
|
||||
urls += url
|
||||
assertEquals("access", bearerToken)
|
||||
return queue.removeFirst()
|
||||
}
|
||||
override suspend fun put(url: String, bearerToken: String) = error("Unexpected PUT")
|
||||
override suspend fun delete(url: String, bearerToken: String) = error("Unexpected DELETE")
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package com.ubisam.ubgitea.domain.history
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@ -132,4 +133,87 @@ class UnifiedDiffParserTest {
|
||||
assertEquals(DiffLine.Removed("bye"), rows[0].left)
|
||||
assertNull(rows[0].right)
|
||||
}
|
||||
|
||||
// --- parseFileEntries ---
|
||||
|
||||
@Test
|
||||
fun `each hunk gets a header entry in front carrying the text after the second @@`() {
|
||||
val patch = """
|
||||
diff --git a/foo.kt b/foo.kt
|
||||
--- a/foo.kt
|
||||
+++ b/foo.kt
|
||||
@@ -10,3 +12,4 @@ fun main() {
|
||||
unchanged
|
||||
-old
|
||||
+new
|
||||
""".trimIndent()
|
||||
|
||||
val entries = UnifiedDiffParser.parseFileEntries(patch, "foo.kt")
|
||||
|
||||
assertTrue(entries[0].isHunkHeader)
|
||||
assertEquals("fun main() {", entries[0].hunkLabel)
|
||||
assertFalse(entries[1].isHunkHeader)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hunk header without trailing text after the second @@ has an empty hunkLabel`() {
|
||||
val patch = """
|
||||
diff --git a/foo.kt b/foo.kt
|
||||
--- a/foo.kt
|
||||
+++ b/foo.kt
|
||||
@@ -1,1 +1,1 @@
|
||||
-a
|
||||
+b
|
||||
""".trimIndent()
|
||||
|
||||
val header = UnifiedDiffParser.parseFileEntries(patch, "foo.kt").first()
|
||||
|
||||
assertTrue(header.isHunkHeader)
|
||||
assertEquals("", header.hunkLabel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `left and right line numbers start from the hunk header's own starting numbers`() {
|
||||
val patch = """
|
||||
diff --git a/foo.kt b/foo.kt
|
||||
--- a/foo.kt
|
||||
+++ b/foo.kt
|
||||
@@ -10,3 +12,4 @@
|
||||
unchanged
|
||||
-old
|
||||
+new
|
||||
+added
|
||||
""".trimIndent()
|
||||
|
||||
val entries = UnifiedDiffParser.parseFileEntries(patch, "foo.kt")
|
||||
val rows = entries.drop(1)
|
||||
|
||||
// "unchanged" is context: present, and numbered, on both sides.
|
||||
assertEquals(10, rows[0].leftNumber)
|
||||
assertEquals(12, rows[0].rightNumber)
|
||||
// "old"/"new" pair up as a change.
|
||||
assertEquals(11, rows[1].leftNumber)
|
||||
assertEquals(13, rows[1].rightNumber)
|
||||
// "added" has no counterpart on the left.
|
||||
assertNull(rows[2].leftNumber)
|
||||
assertEquals(14, rows[2].rightNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an addition-only row has a null leftNumber`() {
|
||||
val patch = """
|
||||
diff --git a/foo.kt b/foo.kt
|
||||
--- a/foo.kt
|
||||
+++ b/foo.kt
|
||||
@@ -1,1 +1,2 @@
|
||||
unchanged
|
||||
+added
|
||||
""".trimIndent()
|
||||
|
||||
val entries = UnifiedDiffParser.parseFileEntries(patch, "foo.kt")
|
||||
val added = entries.last()
|
||||
|
||||
assertNull(added.leftNumber)
|
||||
assertEquals(2, added.rightNumber)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,167 @@
|
||||
package com.ubisam.ubgitea.feature.codebrowser.content
|
||||
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CodeSearchTest {
|
||||
// --- findMatches ---
|
||||
|
||||
@Test
|
||||
fun `matches are case-insensitive by default`() {
|
||||
assertEquals(listOf(SearchMatch(0, 6, 11)), findMatches(listOf("Hello world"), "WORLD"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case-sensitive search when ignoreCase is false`() {
|
||||
assertTrue(findMatches(listOf("Hello world"), "WORLD", ignoreCase = false).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated matches on one line advance past each match without overlapping`() {
|
||||
assertEquals(listOf(SearchMatch(0, 0, 2), SearchMatch(0, 2, 4)), findMatches(listOf("aaaa"), "aa"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matches across lines are returned in reading order`() {
|
||||
val matches = findMatches(listOf("cat", "no match here", "cat cat"), "cat")
|
||||
assertEquals(
|
||||
listOf(SearchMatch(0, 0, 3), SearchMatch(2, 0, 3), SearchMatch(2, 4, 7)),
|
||||
matches,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank query matches nothing`() {
|
||||
assertTrue(findMatches(listOf("anything"), "").isEmpty())
|
||||
}
|
||||
|
||||
// --- firstMatchFrom ---
|
||||
|
||||
@Test
|
||||
fun `firstMatchFrom returns the index of the first match at or after the given line`() {
|
||||
val matches = listOf(SearchMatch(1, 0, 1), SearchMatch(3, 0, 1), SearchMatch(5, 0, 1))
|
||||
assertEquals(1, firstMatchFrom(matches, 2))
|
||||
assertEquals(1, firstMatchFrom(matches, 3))
|
||||
assertEquals(2, firstMatchFrom(matches, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `firstMatchFrom wraps to the first match when none are at or after the line`() {
|
||||
val matches = listOf(SearchMatch(1, 0, 1), SearchMatch(3, 0, 1))
|
||||
assertEquals(0, firstMatchFrom(matches, 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `firstMatchFrom on an empty match list is 0`() {
|
||||
assertEquals(0, firstMatchFrom(emptyList(), 5))
|
||||
}
|
||||
|
||||
// --- parseLineNumber ---
|
||||
|
||||
@Test
|
||||
fun `parses a plain line number within range`() {
|
||||
assertEquals(12, parseLineNumber("12", lineCount = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses a colon-prefixed line number`() {
|
||||
assertEquals(12, parseLineNumber(":12", lineCount = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trims surrounding whitespace before and after the colon`() {
|
||||
assertEquals(12, parseLineNumber(" 12 ", lineCount = 100))
|
||||
assertEquals(12, parseLineNumber(" :12 ", lineCount = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a line number past the end of the file`() {
|
||||
assertNull(parseLineNumber("101", lineCount = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects zero`() {
|
||||
assertNull(parseLineNumber("0", lineCount = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a negative number`() {
|
||||
assertNull(parseLineNumber("-1", lineCount = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects blank input`() {
|
||||
assertNull(parseLineNumber(" ", lineCount = 100))
|
||||
assertNull(parseLineNumber("", lineCount = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects non-numeric input`() {
|
||||
assertNull(parseLineNumber("abc", lineCount = 100))
|
||||
}
|
||||
|
||||
// --- cppRoleOf ---
|
||||
|
||||
@Test
|
||||
fun `header extensions map to the HEADER role`() {
|
||||
listOf("h", "hpp", "hh", "hxx").forEach { extension ->
|
||||
assertEquals(CppFileRole.HEADER, cppRoleOf(RepositoryPath.of("src/Foo.$extension")))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `source extensions map to the SOURCE role`() {
|
||||
listOf("cpp", "cc", "cxx", "c").forEach { extension ->
|
||||
assertEquals(CppFileRole.SOURCE, cppRoleOf(RepositoryPath.of("src/Foo.$extension")))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-C plus plus extension has no role`() {
|
||||
assertNull(cppRoleOf(RepositoryPath.of("src/Foo.kt")))
|
||||
}
|
||||
|
||||
// --- counterpartOf ---
|
||||
|
||||
@Test
|
||||
fun `a header's counterpart is the same-directory same-name source`() {
|
||||
val header = RepositoryPath.of("src/a/Foo.h")
|
||||
val siblings = listOf(header, RepositoryPath.of("src/a/Foo.cpp"), RepositoryPath.of("src/a/Bar.cpp"))
|
||||
assertEquals(RepositoryPath.of("src/a/Foo.cpp"), counterpartOf(header, siblings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a source's counterpart is the same-directory same-name header`() {
|
||||
val source = RepositoryPath.of("src/a/Foo.cpp")
|
||||
val siblings = listOf(source, RepositoryPath.of("src/a/Foo.h"))
|
||||
assertEquals(RepositoryPath.of("src/a/Foo.h"), counterpartOf(source, siblings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cpp is preferred over cc when both exist`() {
|
||||
val header = RepositoryPath.of("src/a/Foo.h")
|
||||
val siblings = listOf(header, RepositoryPath.of("src/a/Foo.cc"), RepositoryPath.of("src/a/Foo.cpp"))
|
||||
assertEquals(RepositoryPath.of("src/a/Foo.cpp"), counterpartOf(header, siblings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a same-named file in a different directory is not a counterpart`() {
|
||||
val header = RepositoryPath.of("src/a/Foo.h")
|
||||
val siblings = listOf(header, RepositoryPath.of("src/b/Foo.cpp"))
|
||||
assertNull(counterpartOf(header, siblings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-C plus plus file has no counterpart`() {
|
||||
assertNull(counterpartOf(RepositoryPath.of("src/a/Foo.kt"), listOf(RepositoryPath.of("src/a/Foo.h"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file is not its own counterpart`() {
|
||||
val header = RepositoryPath.of("src/a/Foo.h")
|
||||
assertNull(counterpartOf(header, listOf(header)))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,241 @@
|
||||
package com.ubisam.ubgitea.feature.codebrowser.content
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SymbolOutlineTest {
|
||||
@Test
|
||||
fun `extracts namespace class fields and methods from Allman-style C sharp`() {
|
||||
val lines = listOf(
|
||||
/* 0 */ "namespace Sample",
|
||||
/* 1 */ "{",
|
||||
/* 2 */ " class RetryPolicy",
|
||||
/* 3 */ " {",
|
||||
/* 4 */ " private int m_attempts;",
|
||||
/* 5 */ " public readonly int MaxAttempts;",
|
||||
/* 6 */ " static readonly int Default = 3;",
|
||||
/* 7 */ " public RetryPolicy(int maxAttempts)",
|
||||
/* 8 */ " {",
|
||||
/* 9 */ " MaxAttempts = maxAttempts;",
|
||||
/* 10 */ " }",
|
||||
/* 11 */ " public int NextDelay(int attempt)",
|
||||
/* 12 */ " {",
|
||||
/* 13 */ " return attempt * 2;",
|
||||
/* 14 */ " }",
|
||||
/* 15 */ " }",
|
||||
/* 16 */ "}",
|
||||
)
|
||||
|
||||
val symbols = extractSymbols(lines, SyntaxLanguage.CSHARP)
|
||||
|
||||
val namespace = symbols.single { it.kind == SymbolKind.NAMESPACE }
|
||||
assertEquals("Sample", namespace.name)
|
||||
assertEquals("Sample", namespace.label)
|
||||
assertEquals(0, namespace.depth)
|
||||
assertEquals(0, namespace.line)
|
||||
assertEquals(16, namespace.endLine)
|
||||
|
||||
val type = symbols.single { it.kind == SymbolKind.TYPE }
|
||||
assertEquals("RetryPolicy", type.name)
|
||||
assertEquals("RetryPolicy", type.label)
|
||||
assertEquals(1, type.depth)
|
||||
assertEquals(2, type.line)
|
||||
assertEquals(15, type.endLine)
|
||||
|
||||
val fields = symbols.filter { it.kind == SymbolKind.FIELD }
|
||||
assertEquals(listOf("m_attempts", "MaxAttempts", "Default"), fields.map { it.name })
|
||||
assertTrue(fields.all { it.depth == 2 })
|
||||
assertEquals(listOf(4, 5, 6), fields.map { it.line })
|
||||
// A field has no body, so its endLine equals its own line.
|
||||
assertEquals(fields.map { it.line }, fields.map { it.endLine })
|
||||
|
||||
val methods = symbols.filter { it.kind == SymbolKind.METHOD }
|
||||
assertEquals(listOf("RetryPolicy(int)", "NextDelay(int)"), methods.map { it.label })
|
||||
assertTrue(methods.all { it.depth == 2 })
|
||||
assertEquals(listOf(7, 11), methods.map { it.line })
|
||||
assertEquals(listOf(10, 14), methods.map { it.endLine })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a C plus plus header lists namespace, class, methods and a field, but not access labels`() {
|
||||
val lines = listOf(
|
||||
/* 0 */ "namespace a::b {",
|
||||
/* 1 */ "class Foo {",
|
||||
/* 2 */ "public:",
|
||||
/* 3 */ " Foo(int x);",
|
||||
/* 4 */ " int Bar(int y) const;",
|
||||
/* 5 */ "private:",
|
||||
/* 6 */ " int m_x;",
|
||||
/* 7 */ "};",
|
||||
/* 8 */ "}",
|
||||
)
|
||||
|
||||
val symbols = extractSymbols(lines, SyntaxLanguage.CPP)
|
||||
|
||||
val namespace = symbols.single { it.kind == SymbolKind.NAMESPACE }
|
||||
assertEquals("a::b", namespace.name)
|
||||
assertEquals(0, namespace.depth)
|
||||
assertEquals(0, namespace.line)
|
||||
assertEquals(8, namespace.endLine)
|
||||
|
||||
val type = symbols.single { it.kind == SymbolKind.TYPE }
|
||||
assertEquals("Foo", type.name)
|
||||
assertEquals(1, type.depth)
|
||||
assertEquals(1, type.line)
|
||||
assertEquals(7, type.endLine)
|
||||
|
||||
val methods = symbols.filter { it.kind == SymbolKind.METHOD }
|
||||
assertEquals(listOf("Foo(int)", "Bar(int)"), methods.map { it.label })
|
||||
assertTrue(methods.all { it.depth == 2 })
|
||||
|
||||
val field = symbols.single { it.kind == SymbolKind.FIELD }
|
||||
assertEquals("m_x", field.name)
|
||||
assertEquals(2, field.depth)
|
||||
|
||||
// "public:" and "private:" are labels, not symbols.
|
||||
assertTrue(symbols.none { it.name == "public" || it.name == "private" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a forward declaration is not listed`() {
|
||||
val symbols = extractSymbols(listOf("class Foo;"), SyntaxLanguage.CPP)
|
||||
assertTrue(symbols.none { it.name == "Foo" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a C plus plus source file names the method after its qualifier and skips the initializer list and body calls`() {
|
||||
val lines = listOf(
|
||||
/* 0 */ "Foo::Foo(int x)",
|
||||
/* 1 */ " : m_x(x)",
|
||||
/* 2 */ "{",
|
||||
/* 3 */ " DoSomething(x);",
|
||||
/* 4 */ "}",
|
||||
)
|
||||
|
||||
val symbols = extractSymbols(lines, SyntaxLanguage.CPP)
|
||||
|
||||
val method = symbols.single()
|
||||
assertEquals("Foo", method.name)
|
||||
assertEquals("Foo(int)", method.label)
|
||||
assertEquals(SymbolKind.METHOD, method.kind)
|
||||
assertEquals(0, method.line)
|
||||
assertEquals(4, method.endLine)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a definition with a return type is named after the method, not the qualifier`() {
|
||||
val lines = listOf(
|
||||
"int Foo::Bar(int x) const {",
|
||||
" return x * 2;",
|
||||
"}",
|
||||
)
|
||||
|
||||
val method = extractSymbols(lines, SyntaxLanguage.CPP).single()
|
||||
assertEquals("Bar", method.name)
|
||||
assertEquals("Bar(int)", method.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `comments and strings that look like declarations are ignored, including multi-line block comments`() {
|
||||
val lines = listOf(
|
||||
/* 0 */ "/*",
|
||||
/* 1 */ " * class Ghost { should be ignored }",
|
||||
/* 2 */ " */",
|
||||
/* 3 */ "int x = 1; // class Fake { ignored }",
|
||||
/* 4 */ "const char* s = \"class AlsoFake { ignored }\";",
|
||||
/* 5 */ "class Real {",
|
||||
/* 6 */ "};",
|
||||
)
|
||||
|
||||
val symbols = extractSymbols(lines, SyntaxLanguage.CPP)
|
||||
|
||||
assertEquals(listOf("Real"), symbols.map { it.name })
|
||||
val real = symbols.single()
|
||||
assertEquals(SymbolKind.TYPE, real.kind)
|
||||
assertEquals(5, real.line)
|
||||
assertEquals(6, real.endLine)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parameters spanning multiple lines still produce an ellipsis label`() {
|
||||
val lines = listOf(
|
||||
"int Foo::LongMethod(int a,",
|
||||
" int b) {",
|
||||
"}",
|
||||
)
|
||||
|
||||
val method = extractSymbols(lines, SyntaxLanguage.CPP).single()
|
||||
assertEquals("LongMethod", method.name)
|
||||
assertEquals("LongMethod(…)", method.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Kotlin extracts a class, a brace-bodied function and an expression-bodied function`() {
|
||||
val lines = listOf(
|
||||
/* 0 */ "class Sample {",
|
||||
/* 1 */ " fun greet(name: String): String {",
|
||||
/* 2 */ " return \"hi \$name\"",
|
||||
/* 3 */ " }",
|
||||
/* 4 */ " fun answer() = 42",
|
||||
/* 5 */ "}",
|
||||
)
|
||||
|
||||
val symbols = extractSymbols(lines, SyntaxLanguage.KOTLIN)
|
||||
|
||||
val type = symbols.single { it.kind == SymbolKind.TYPE }
|
||||
assertEquals("Sample", type.name)
|
||||
assertEquals(0, type.depth)
|
||||
assertEquals(0, type.line)
|
||||
assertEquals(5, type.endLine)
|
||||
|
||||
val methods = symbols.filter { it.kind == SymbolKind.METHOD }
|
||||
assertEquals(listOf("greet(String)", "answer()"), methods.map { it.label })
|
||||
assertTrue(methods.all { it.depth == 1 })
|
||||
assertEquals(listOf(1, 4), methods.map { it.line })
|
||||
assertEquals(listOf(3, 4), methods.map { it.endLine })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unsupported language yields no symbols and no outline support`() {
|
||||
assertFalse(supportsOutline(SyntaxLanguage.PYTHON))
|
||||
assertTrue(extractSymbols(listOf("class Foo:", " def bar(self): pass"), SyntaxLanguage.PYTHON).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enclosingSymbols returns namespace class then method, outermost first, excluding fields`() {
|
||||
val lines = listOf(
|
||||
/* 0 */ "namespace Sample",
|
||||
/* 1 */ "{",
|
||||
/* 2 */ " class RetryPolicy",
|
||||
/* 3 */ " {",
|
||||
/* 4 */ " private int m_attempts;",
|
||||
/* 5 */ " public int NextDelay(int attempt)",
|
||||
/* 6 */ " {",
|
||||
/* 7 */ " return attempt * 2;",
|
||||
/* 8 */ " }",
|
||||
/* 9 */ " }",
|
||||
/* 10 */ "}",
|
||||
)
|
||||
val symbols = extractSymbols(lines, SyntaxLanguage.CSHARP)
|
||||
|
||||
val enclosing = enclosingSymbols(symbols, line = 7)
|
||||
|
||||
assertEquals(listOf(SymbolKind.NAMESPACE, SymbolKind.TYPE, SymbolKind.METHOD), enclosing.map { it.kind })
|
||||
assertEquals(listOf("Sample", "RetryPolicy", "NextDelay"), enclosing.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findSymbolNamed prefers a matching kind and falls back to any match by name`() {
|
||||
val symbols = listOf(
|
||||
CodeSymbol("Foo", "Foo", SymbolKind.FIELD, line = 0, endLine = 0, depth = 1),
|
||||
CodeSymbol("Foo", "Foo()", SymbolKind.METHOD, line = 1, endLine = 2, depth = 1),
|
||||
)
|
||||
|
||||
assertEquals(SymbolKind.METHOD, findSymbolNamed(symbols, "Foo", SymbolKind.METHOD)?.kind)
|
||||
assertEquals(SymbolKind.FIELD, findSymbolNamed(symbols, "Foo")?.kind)
|
||||
assertNull(findSymbolNamed(symbols, "Bar"))
|
||||
}
|
||||
}
|
||||
@ -85,7 +85,12 @@ class SyntaxHighlighterTest {
|
||||
@Test
|
||||
fun colorsHexAndSuffixedNumbers() {
|
||||
assertEquals(
|
||||
listOf("0xFF" to TokenRole.NUMBER, "2.5f" to TokenRole.NUMBER, "10UL" to TokenRole.NUMBER),
|
||||
listOf(
|
||||
"a" to TokenRole.METHOD,
|
||||
"0xFF" to TokenRole.NUMBER,
|
||||
"2.5f" to TokenRole.NUMBER,
|
||||
"10UL" to TokenRole.NUMBER,
|
||||
),
|
||||
tokens(SyntaxLanguage.CPP, "a(0xFF, 2.5f, 10UL);").single(),
|
||||
)
|
||||
}
|
||||
@ -112,7 +117,7 @@ class SyntaxHighlighterTest {
|
||||
val result = tokens(SyntaxLanguage.CPP, "int a; /* starts", "ends */ return 1;")
|
||||
assertEquals(listOf("int" to TokenRole.KEYWORD, "/* starts" to TokenRole.COMMENT), result[0])
|
||||
assertEquals(
|
||||
listOf("ends */" to TokenRole.COMMENT, "return" to TokenRole.KEYWORD, "1" to TokenRole.NUMBER),
|
||||
listOf("ends */" to TokenRole.COMMENT, "return" to TokenRole.CONTROL_KEYWORD, "1" to TokenRole.NUMBER),
|
||||
result[1],
|
||||
)
|
||||
}
|
||||
@ -173,4 +178,102 @@ class SyntaxHighlighterTest {
|
||||
assertEquals(listOf("int a;" to TokenRole.COMMENT), result[1])
|
||||
assertEquals(listOf("int b;" to TokenRole.COMMENT), result[2])
|
||||
}
|
||||
|
||||
// --- non-keyword identifiers (TASK-053 redesign) ---
|
||||
|
||||
@Test
|
||||
fun colorsIdentifierFollowedByParenthesisAsMethod() {
|
||||
val line = tokens(SyntaxLanguage.CSHARP, "doRetry(attempt);").single()
|
||||
assertEquals("doRetry" to TokenRole.METHOD, line.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun colorsIdentifierAfterNewAsTypeNotMethod() {
|
||||
val line = tokens(SyntaxLanguage.CSHARP, "var p = new RetryPolicy(3);").single()
|
||||
assertEquals(
|
||||
listOf(
|
||||
"var" to TokenRole.KEYWORD,
|
||||
"new" to TokenRole.KEYWORD,
|
||||
"RetryPolicy" to TokenRole.TYPE,
|
||||
"3" to TokenRole.NUMBER,
|
||||
),
|
||||
line,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun colorsConstructorCallAfterNewInCpp() {
|
||||
val line = tokens(SyntaxLanguage.CPP, "auto p = new RetryPolicy(3);").single()
|
||||
assertEquals(
|
||||
listOf(
|
||||
"auto" to TokenRole.KEYWORD,
|
||||
"new" to TokenRole.KEYWORD,
|
||||
"RetryPolicy" to TokenRole.TYPE,
|
||||
"3" to TokenRole.NUMBER,
|
||||
),
|
||||
line,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun colorsCapitalizedIdentifierAsType() {
|
||||
val line = tokens(SyntaxLanguage.CPP, "RetryPolicy policy;").single()
|
||||
assertEquals(listOf("RetryPolicy" to TokenRole.TYPE), line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun excludesCapitalizedMemberAccessFromType() {
|
||||
val line = tokens(SyntaxLanguage.CSHARP, "var x = obj.Value;").single()
|
||||
assertTrue(line.none { it.second == TokenRole.TYPE })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotColorAllCapsIdentifierAsType() {
|
||||
val line = tokens(SyntaxLanguage.CPP, "int MAX_RETRY;").single()
|
||||
assertEquals(listOf("int" to TokenRole.KEYWORD), line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun colorsCTypeSuffixAsType() {
|
||||
val line = tokens(SyntaxLanguage.CPP, "size_t count;").single()
|
||||
assertEquals(listOf("size_t" to TokenRole.TYPE), line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotColorMemberPrefixedIdentifier() {
|
||||
val line = tokens(SyntaxLanguage.CPP, "int m_count;").single()
|
||||
assertEquals(listOf("int" to TokenRole.KEYWORD), line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotColorUnderscorePrefixedIdentifier() {
|
||||
val line = tokens(SyntaxLanguage.CPP, "int _count;").single()
|
||||
assertEquals(listOf("int" to TokenRole.KEYWORD), line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun colorsIfElseReturnThrowAsControlKeywords() {
|
||||
val line = tokens(SyntaxLanguage.CSHARP, "if (x) return; else throw new Exception();").single()
|
||||
val controlWords = line.filter { it.second == TokenRole.CONTROL_KEYWORD }.map { it.first }
|
||||
assertEquals(listOf("if", "return", "else", "throw"), controlWords)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun colorsForAndWhileAsControlKeywords() {
|
||||
val line = tokens(SyntaxLanguage.CPP, "for (;;) while (true) {}").single()
|
||||
val controlWords = line.filter { it.second == TokenRole.CONTROL_KEYWORD }.map { it.first }
|
||||
assertEquals(listOf("for", "while"), controlWords)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesIdentifiersUncoloredOnCSharpUsingLine() {
|
||||
val line = tokens(SyntaxLanguage.CSHARP, "using MyCompany.Utils;").single()
|
||||
assertEquals(listOf("using" to TokenRole.KEYWORD), line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesIdentifierUncoloredOnCppNamespaceDeclarationLine() {
|
||||
val line = tokens(SyntaxLanguage.CPP, "namespace Foo {").single()
|
||||
assertEquals(listOf("namespace" to TokenRole.KEYWORD), line)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
package com.ubisam.ubgitea.feature.history
|
||||
|
||||
import com.ubisam.ubgitea.domain.history.DiffEntry
|
||||
import com.ubisam.ubgitea.domain.history.DiffLine
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FileDiffChangesTest {
|
||||
private fun context(text: String) = DiffLine.Context(text)
|
||||
private fun header(label: String = "") = DiffEntry(null, null, hunkLabel = label)
|
||||
private fun contextRow(text: String) = DiffEntry(context(text), context(text), 1, 1)
|
||||
private fun changeRow(removed: String? = "old", added: String? = "new") = DiffEntry(
|
||||
left = removed?.let(DiffLine::Removed),
|
||||
right = added?.let(DiffLine::Added),
|
||||
leftNumber = removed?.let { 1 },
|
||||
rightNumber = added?.let { 1 },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `returns only the first index of each consecutive run of changed rows`() {
|
||||
val entries = listOf(
|
||||
header(), // 0
|
||||
contextRow("a"), // 1
|
||||
changeRow(), // 2 - start of a run
|
||||
changeRow(), // 3 - continues the run
|
||||
contextRow("b"), // 4
|
||||
changeRow(removed = null), // 5 - start of a new run (addition only)
|
||||
changeRow(removed = null), // 6 - continues the run
|
||||
contextRow("c"), // 7
|
||||
)
|
||||
|
||||
assertEquals(listOf(2, 5), changeStartsOf(entries))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a change on the very first row is still returned`() {
|
||||
val entries = listOf(changeRow(), contextRow("a"))
|
||||
|
||||
assertEquals(listOf(0), changeStartsOf(entries))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no changes yields an empty list`() {
|
||||
val entries = listOf(header(), contextRow("a"), contextRow("b"))
|
||||
|
||||
assertTrue(changeStartsOf(entries).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hunk headers are never treated as changes`() {
|
||||
val entries = listOf(header("fun main() {"), changeRow())
|
||||
|
||||
assertEquals(listOf(1), changeStartsOf(entries))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
package com.ubisam.ubgitea.feature.home
|
||||
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityItem
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityKind
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class HomeSummariesTest {
|
||||
private val zone = ZoneId.of("Asia/Seoul")
|
||||
private val today = LocalDate.of(2026, 9, 28)
|
||||
private val repoA = RepositoryCoordinates.of("team", "alpha")
|
||||
private val repoB = RepositoryCoordinates.of("team", "beta")
|
||||
|
||||
private fun epochMillisAt(date: LocalDate, hour: Int, minute: Int = 0): Long =
|
||||
date.atTime(hour, minute).atZone(zone).toInstant().toEpochMilli()
|
||||
|
||||
private fun activity(
|
||||
id: Long,
|
||||
repository: RepositoryCoordinates,
|
||||
date: LocalDate,
|
||||
hour: Int = 12,
|
||||
kind: ActivityKind = ActivityKind.PUSH,
|
||||
refName: String? = null,
|
||||
) = ActivityItem(
|
||||
id = id,
|
||||
kind = kind,
|
||||
actorLogin = "actor",
|
||||
actorDisplayName = null,
|
||||
repository = repository,
|
||||
refName = refName,
|
||||
subjectNumber = null,
|
||||
subjectText = null,
|
||||
commits = emptyList(),
|
||||
commitCount = 0,
|
||||
createdAtEpochMillis = epochMillisAt(date, hour),
|
||||
)
|
||||
|
||||
// --- activeRepositoriesOf ---
|
||||
|
||||
@Test
|
||||
fun `sorts repositories by most recent activity`() {
|
||||
val items = listOf(
|
||||
activity(1, repoA, today.minusDays(2)),
|
||||
activity(2, repoB, today),
|
||||
)
|
||||
|
||||
val result = activeRepositoriesOf(items, today, zone)
|
||||
|
||||
assertEquals(listOf(repoB, repoA), result.map { it.repository })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `limit caps the number of repositories returned`() {
|
||||
val items = (1..10).map { activity(it.toLong(), RepositoryCoordinates.of("team", "repo$it"), today) }
|
||||
|
||||
val result = activeRepositoriesOf(items, today, zone, limit = 3)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `activity outside the 7 day window is not counted in the daily bars`() {
|
||||
val items = listOf(
|
||||
activity(1, repoA, today.minusDays(10)),
|
||||
activity(2, repoA, today),
|
||||
)
|
||||
|
||||
val result = activeRepositoriesOf(items, today, zone).single()
|
||||
|
||||
assertEquals(1, result.dailyCounts.sum())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dailyCounts has 7 entries and the last one is today`() {
|
||||
val items = listOf(activity(1, repoA, today))
|
||||
|
||||
val result = activeRepositoriesOf(items, today, zone).single()
|
||||
|
||||
assertEquals(7, result.dailyCounts.size)
|
||||
assertEquals(1, result.dailyCounts.last())
|
||||
assertEquals(0, result.dailyCounts.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `latestRefName is from the most recent activity that has a ref`() {
|
||||
val items = listOf(
|
||||
activity(1, repoA, today.minusDays(1), refName = "feature-old"),
|
||||
activity(2, repoA, today, refName = null),
|
||||
)
|
||||
|
||||
val result = activeRepositoriesOf(items, today, zone).single()
|
||||
|
||||
assertEquals("feature-old", result.latestRefName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `latestRefName is null when no activity in the repository has a ref`() {
|
||||
val items = listOf(activity(1, repoA, today, refName = null))
|
||||
|
||||
val result = activeRepositoriesOf(items, today, zone).single()
|
||||
|
||||
assertNull(result.latestRefName)
|
||||
}
|
||||
|
||||
// --- todayCountsOf ---
|
||||
|
||||
@Test
|
||||
fun `counts only pushes and opened issues from today`() {
|
||||
val items = listOf(
|
||||
activity(1, repoA, today, kind = ActivityKind.PUSH),
|
||||
activity(2, repoA, today, kind = ActivityKind.PUSH),
|
||||
activity(3, repoA, today, kind = ActivityKind.ISSUE_OPENED),
|
||||
activity(4, repoA, today, kind = ActivityKind.ISSUE_CLOSED),
|
||||
activity(5, repoA, today, kind = ActivityKind.PULL_REQUEST_OPENED),
|
||||
)
|
||||
|
||||
val counts = todayCountsOf(items, today, zone)
|
||||
|
||||
assertEquals(2, counts.pushes)
|
||||
assertEquals(1, counts.issuesOpened)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `excludes activity from yesterday`() {
|
||||
val items = listOf(activity(1, repoA, today.minusDays(1), kind = ActivityKind.PUSH))
|
||||
|
||||
val counts = todayCountsOf(items, today, zone)
|
||||
|
||||
assertEquals(0, counts.pushes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `respects the midnight boundary in the Seoul time zone`() {
|
||||
val lateYesterday = activity(1, repoA, today.minusDays(1), hour = 23, kind = ActivityKind.PUSH)
|
||||
.copy(createdAtEpochMillis = epochMillisAt(today.minusDays(1), hour = 23, minute = 30))
|
||||
val earlyToday = activity(2, repoA, today, kind = ActivityKind.PUSH)
|
||||
.copy(createdAtEpochMillis = epochMillisAt(today, hour = 0, minute = 30))
|
||||
|
||||
val counts = todayCountsOf(listOf(lateYesterday, earlyToday), today, zone)
|
||||
|
||||
assertEquals(1, counts.pushes)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,227 @@
|
||||
package com.ubisam.ubgitea.feature.home
|
||||
|
||||
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||
import com.ubisam.ubgitea.data.auth.AuthenticatedUser
|
||||
import com.ubisam.ubgitea.data.auth.AuthenticationGateway
|
||||
import com.ubisam.ubgitea.data.auth.TokenSet
|
||||
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityCategory
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityItem
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityKind
|
||||
import com.ubisam.ubgitea.domain.activity.ActivityRepository
|
||||
import com.ubisam.ubgitea.domain.model.Page
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositorySummary
|
||||
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
|
||||
import com.ubisam.ubgitea.domain.organizations.OrganizationsRepository
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkScope
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchRepository
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkItemHit
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class HomeViewModelTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
|
||||
|
||||
@After
|
||||
fun resetMainDispatcher() = Dispatchers.resetMain()
|
||||
|
||||
private val tokenProvider = BearerTokenProvider { "token" }
|
||||
private val zone = ZoneId.of("Asia/Seoul")
|
||||
|
||||
private fun viewModel(
|
||||
activity: ActivityRepository,
|
||||
workSearch: WorkSearchRepository = FakeWorkSearchRepository { 0 },
|
||||
organizations: OrganizationsRepository = FakeOrganizationsRepository(),
|
||||
) = HomeViewModel(
|
||||
authGateway = FakeAuthGateway(),
|
||||
tokenProvider = tokenProvider,
|
||||
activity = activity,
|
||||
workSearch = workSearch,
|
||||
organizations = organizations,
|
||||
now = { 1_700_000_000_000L },
|
||||
zone = { zone },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a successful load produces Content`() = runTest(dispatcher.scheduler) {
|
||||
val activity = FakeActivityRepository { _, _, _ -> Page.of(listOf(item(1)), null) }
|
||||
val vm = viewModel(activity)
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(vm.state.value is HomeUiState.Content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a feed failure maps to a typed error`() = runTest(dispatcher.scheduler) {
|
||||
val activity = FakeActivityRepository { _, _, _ -> throw ApiFailure.AuthenticationExpired() }
|
||||
val vm = viewModel(activity)
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value as HomeUiState.Error
|
||||
assertEquals(HomeError.AUTHENTICATION_EXPIRED, state.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an organization listing failure is tolerated and Content still loads`() = runTest(dispatcher.scheduler) {
|
||||
val activity = FakeActivityRepository { _, _, _ -> Page.of(listOf(item(1)), null) }
|
||||
val organizations = FakeOrganizationsRepository(fail = true)
|
||||
val vm = viewModel(activity, organizations = organizations)
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value as HomeUiState.Content
|
||||
assertTrue(state.organizations.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one failing summary count leaves the other counts filled`() = runTest(dispatcher.scheduler) {
|
||||
val activity = FakeActivityRepository { _, _, _ -> Page.of(emptyList(), null) }
|
||||
val workSearch = FakeWorkSearchRepository { query ->
|
||||
if (query.scope == WorkScope.REVIEW_REQUESTED) throw ApiFailure.Server() else 5
|
||||
}
|
||||
val vm = viewModel(activity, workSearch = workSearch)
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value as HomeUiState.Content
|
||||
assertNull(state.summary.reviewRequested)
|
||||
assertEquals(5, state.summary.openPullRequests)
|
||||
assertEquals(5, state.summary.assignedIssues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectFilter narrows visibleTimeline to the chosen category`() = runTest(dispatcher.scheduler) {
|
||||
val items = listOf(item(1, kind = ActivityKind.PUSH), item(2, kind = ActivityKind.ISSUE_OPENED))
|
||||
val activity = FakeActivityRepository { _, _, _ -> Page.of(items, null) }
|
||||
val vm = viewModel(activity)
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
vm.selectFilter(ActivityCategory.ISSUE)
|
||||
|
||||
val state = vm.state.value as HomeUiState.Content
|
||||
assertEquals(listOf(2L), state.visibleTimeline.map(ActivityItem::id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadMore appends results without duplicate ids`() = runTest(dispatcher.scheduler) {
|
||||
val activity = FakeActivityRepository { _, _, page ->
|
||||
when (page) {
|
||||
1 -> Page.of(listOf(item(1), item(2)), nextPage = 2)
|
||||
else -> Page.of(listOf(item(2), item(3)), nextPage = null)
|
||||
}
|
||||
}
|
||||
val vm = viewModel(activity)
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
vm.loadMore()
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value as HomeUiState.Content
|
||||
assertEquals(listOf(1L, 2L, 3L), state.timeline.map(ActivityItem::id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calling open twice loads only once`() = runTest(dispatcher.scheduler) {
|
||||
var mainFeedCalls = 0
|
||||
val activity = FakeActivityRepository { _, date, _ ->
|
||||
if (date == null) mainFeedCalls++
|
||||
Page.of(listOf(item(1)), null)
|
||||
}
|
||||
val vm = viewModel(activity)
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, mainFeedCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close returns to Idle`() = runTest(dispatcher.scheduler) {
|
||||
val activity = FakeActivityRepository { _, _, _ -> Page.of(listOf(item(1)), null) }
|
||||
val vm = viewModel(activity)
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
vm.close()
|
||||
|
||||
assertEquals(HomeUiState.Idle, vm.state.value)
|
||||
}
|
||||
|
||||
private fun item(id: Long, kind: ActivityKind = ActivityKind.PUSH) = ActivityItem(
|
||||
id = id,
|
||||
kind = kind,
|
||||
actorLogin = "octocat",
|
||||
actorDisplayName = null,
|
||||
repository = RepositoryCoordinates.of("team", "repo"),
|
||||
refName = null,
|
||||
subjectNumber = null,
|
||||
subjectText = null,
|
||||
commits = emptyList(),
|
||||
commitCount = 0,
|
||||
createdAtEpochMillis = 0L,
|
||||
)
|
||||
|
||||
private class FakeAuthGateway(private val login: String = "octocat") : AuthenticationGateway {
|
||||
override suspend fun exchangeCode(authorizationCode: String, verifier: String, nowEpochMillis: Long): TokenSet =
|
||||
error("Not used")
|
||||
override suspend fun refreshToken(refreshToken: String, nowEpochMillis: Long): TokenSet = error("Not used")
|
||||
override suspend fun getAuthenticatedUser(accessToken: String) = AuthenticatedUser(1, login, null)
|
||||
}
|
||||
|
||||
/** [feed] receives (organization, date, page); organization is null for the user's own feed. */
|
||||
private class FakeActivityRepository(
|
||||
private val feed: (organization: String?, date: LocalDate?, page: Int) -> Page<ActivityItem>,
|
||||
) : ActivityRepository {
|
||||
override suspend fun listUserFeed(login: String, date: LocalDate?, page: Int, pageSize: Int) =
|
||||
feed(null, date, page)
|
||||
override suspend fun listOrganizationFeed(organization: String, date: LocalDate?, page: Int, pageSize: Int) =
|
||||
feed(organization, date, page)
|
||||
}
|
||||
|
||||
private class FakeWorkSearchRepository(private val counts: (WorkSearchQuery) -> Int?) : WorkSearchRepository {
|
||||
override suspend fun search(query: WorkSearchQuery, page: Int, pageSize: Int): Page<WorkItemHit> =
|
||||
Page.of(emptyList(), null)
|
||||
override suspend fun count(query: WorkSearchQuery): Int? = counts(query)
|
||||
}
|
||||
|
||||
private class FakeOrganizationsRepository(
|
||||
private val organizations: List<OrganizationSummary> = emptyList(),
|
||||
private val fail: Boolean = false,
|
||||
) : OrganizationsRepository {
|
||||
override suspend fun listOrganizations(page: Int, pageSize: Int): Page<OrganizationSummary> {
|
||||
if (fail) throw ApiFailure.Server()
|
||||
return Page.of(organizations, null)
|
||||
}
|
||||
override suspend fun listOrganizationRepositories(organization: String, page: Int, pageSize: Int): Page<RepositorySummary> =
|
||||
error("Not used")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,191 @@
|
||||
package com.ubisam.ubgitea.feature.work
|
||||
|
||||
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||
import com.ubisam.ubgitea.domain.model.Page
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkItemAuthor
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkItemHit
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkScope
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchQuery
|
||||
import com.ubisam.ubgitea.domain.workitems.WorkSearchRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class WorkViewModelTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
|
||||
|
||||
@After
|
||||
fun resetMainDispatcher() = Dispatchers.resetMain()
|
||||
|
||||
@Test
|
||||
fun `open loads only when Idle`() = runTest(dispatcher.scheduler) {
|
||||
var calls = 0
|
||||
val repo = FakeWorkSearchRepository { _, _ -> calls++; Page.of(listOf(hit(1)), null) }
|
||||
val vm = WorkViewModel(repo)
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectScope reloads from page 1 with the new scope`() = runTest(dispatcher.scheduler) {
|
||||
val pages = mutableListOf<Int>()
|
||||
val repo = FakeWorkSearchRepository { _, page ->
|
||||
pages += page
|
||||
Page.of(listOf(hit(1)), if (page == 1) 2 else null)
|
||||
}
|
||||
val vm = WorkViewModel(repo)
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
vm.nextPage()
|
||||
advanceUntilIdle()
|
||||
pages.clear()
|
||||
|
||||
vm.selectScope(WorkScope.ASSIGNED_TO_ME)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf(1), pages)
|
||||
assertEquals(WorkScope.ASSIGNED_TO_ME, vm.state.value.query.scope)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectState reloads from page 1 with the new state`() = runTest(dispatcher.scheduler) {
|
||||
val pages = mutableListOf<Int>()
|
||||
val repo = FakeWorkSearchRepository { _, page ->
|
||||
pages += page
|
||||
Page.of(listOf(hit(1)), if (page == 1) 2 else null)
|
||||
}
|
||||
val vm = WorkViewModel(repo)
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
vm.nextPage()
|
||||
advanceUntilIdle()
|
||||
pages.clear()
|
||||
|
||||
vm.selectState(com.ubisam.ubgitea.domain.workitems.WorkStateFilter.CLOSED)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf(1), pages)
|
||||
assertEquals(com.ubisam.ubgitea.domain.workitems.WorkStateFilter.CLOSED, vm.state.value.query.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectType reloads from page 1 with the new type`() = runTest(dispatcher.scheduler) {
|
||||
val pages = mutableListOf<Int>()
|
||||
val repo = FakeWorkSearchRepository { _, page ->
|
||||
pages += page
|
||||
Page.of(listOf(hit(1)), if (page == 1) 2 else null)
|
||||
}
|
||||
val vm = WorkViewModel(repo)
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
vm.nextPage()
|
||||
advanceUntilIdle()
|
||||
pages.clear()
|
||||
|
||||
vm.selectType(com.ubisam.ubgitea.domain.workitems.WorkTypeFilter.ISSUES)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf(1), pages)
|
||||
assertEquals(com.ubisam.ubgitea.domain.workitems.WorkTypeFilter.ISSUES, vm.state.value.query.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no results produces Empty`() = runTest(dispatcher.scheduler) {
|
||||
val repo = FakeWorkSearchRepository { _, _ -> Page.of(emptyList(), null) }
|
||||
val vm = WorkViewModel(repo)
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(vm.state.value is WorkUiState.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nextPage and previousPage navigate between pages`() = runTest(dispatcher.scheduler) {
|
||||
val repo = FakeWorkSearchRepository { _, page ->
|
||||
when (page) {
|
||||
1 -> Page.of(listOf(hit(1)), 2)
|
||||
2 -> Page.of(listOf(hit(2)), null)
|
||||
else -> Page.of(emptyList(), null)
|
||||
}
|
||||
}
|
||||
val vm = WorkViewModel(repo)
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.nextPage()
|
||||
advanceUntilIdle()
|
||||
var content = vm.state.value as WorkUiState.Content
|
||||
assertEquals(2, content.page)
|
||||
assertEquals(listOf(2L), content.items.map(WorkItemHit::number))
|
||||
|
||||
vm.previousPage()
|
||||
advanceUntilIdle()
|
||||
content = vm.state.value as WorkUiState.Content
|
||||
assertEquals(1, content.page)
|
||||
assertEquals(listOf(1L), content.items.map(WorkItemHit::number))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a search failure maps to a typed error`() = runTest(dispatcher.scheduler) {
|
||||
val repo = FakeWorkSearchRepository { _, _ -> throw ApiFailure.PermissionDenied() }
|
||||
val vm = WorkViewModel(repo)
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
|
||||
val error = vm.state.value as WorkUiState.Error
|
||||
assertEquals(WorkError.PERMISSION_DENIED, error.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an offline failure maps to OFFLINE`() = runTest(dispatcher.scheduler) {
|
||||
val repo = FakeWorkSearchRepository { _, _ -> throw java.io.IOException("no network") }
|
||||
val vm = WorkViewModel(repo)
|
||||
|
||||
vm.open()
|
||||
advanceUntilIdle()
|
||||
|
||||
val error = vm.state.value as WorkUiState.Error
|
||||
assertEquals(WorkError.OFFLINE, error.type)
|
||||
}
|
||||
|
||||
private fun hit(number: Long) = WorkItemHit(
|
||||
repository = RepositoryCoordinates.of("team", "repo"),
|
||||
number = number,
|
||||
title = "Item $number",
|
||||
isPullRequest = false,
|
||||
isOpen = true,
|
||||
isMerged = false,
|
||||
author = WorkItemAuthor.Unavailable,
|
||||
updatedAtEpochMillis = 0L,
|
||||
commentCount = 0,
|
||||
)
|
||||
|
||||
private class FakeWorkSearchRepository(
|
||||
private val search: (query: WorkSearchQuery, page: Int) -> Page<WorkItemHit>,
|
||||
) : WorkSearchRepository {
|
||||
override suspend fun search(query: WorkSearchQuery, page: Int, pageSize: Int): Page<WorkItemHit> =
|
||||
search(query, page)
|
||||
override suspend fun count(query: WorkSearchQuery): Int? = null
|
||||
}
|
||||
}
|
||||
61
app/src/test/java/com/ubisam/ubgitea/ui/RelativeTimeTest.kt
Normal file
61
app/src/test/java/com/ubisam/ubgitea/ui/RelativeTimeTest.kt
Normal file
@ -0,0 +1,61 @@
|
||||
package com.ubisam.ubgitea.ui
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RelativeTimeTest {
|
||||
private val zone = ZoneId.of("Asia/Seoul")
|
||||
|
||||
private fun millisAt(year: Int, month: Int, day: Int, hour: Int, minute: Int, second: Int = 0): Long =
|
||||
LocalDateTime.of(year, month, day, hour, minute, second).atZone(zone).toInstant().toEpochMilli()
|
||||
|
||||
@Test
|
||||
fun `under a minute is JustNow`() {
|
||||
val now = millisAt(2026, 9, 28, 15, 0, 30)
|
||||
val then = millisAt(2026, 9, 28, 15, 0, 0)
|
||||
|
||||
assertEquals(RelativeTime.JustNow, relativeTimeOf(then, now, zone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `59 minutes ago is Minutes(59)`() {
|
||||
val now = millisAt(2026, 9, 28, 15, 0, 0)
|
||||
val then = millisAt(2026, 9, 28, 14, 1, 0)
|
||||
|
||||
assertEquals(RelativeTime.Minutes(59), relativeTimeOf(then, now, zone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `3 hours ago on the same day is Hours(3)`() {
|
||||
val now = millisAt(2026, 9, 28, 15, 0, 0)
|
||||
val then = millisAt(2026, 9, 28, 12, 0, 0)
|
||||
|
||||
assertEquals(RelativeTime.Hours(3), relativeTimeOf(then, now, zone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `2 hours ago that crosses midnight is Yesterday, not Hours`() {
|
||||
val now = millisAt(2026, 9, 28, 1, 0, 0)
|
||||
val then = millisAt(2026, 9, 27, 23, 0, 0)
|
||||
|
||||
assertEquals(RelativeTime.Yesterday, relativeTimeOf(then, now, zone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `3 days ago is Days(3)`() {
|
||||
val now = millisAt(2026, 9, 28, 15, 0, 0)
|
||||
val then = millisAt(2026, 9, 25, 15, 0, 0)
|
||||
|
||||
assertEquals(RelativeTime.Days(3), relativeTimeOf(then, now, zone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `7 or more days ago is a plain date`() {
|
||||
val now = millisAt(2026, 9, 28, 15, 0, 0)
|
||||
val then = millisAt(2026, 9, 21, 15, 0, 0)
|
||||
|
||||
assertEquals(RelativeTime.OnDate("2026-09-21"), relativeTimeOf(then, now, zone))
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ package com.ubisam.ubgitea.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class RouteEncodingTest {
|
||||
@ -10,6 +11,45 @@ class RouteEncodingTest {
|
||||
assertEquals(Route.Repositories, RouteEncoding.decode(RouteEncoding.encode(Route.Repositories)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home route round-trips`() {
|
||||
assertEquals(Route.Home, RouteEncoding.decode(RouteEncoding.encode(Route.Home)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `work route round-trips`() {
|
||||
assertEquals(Route.Work, RouteEncoding.decode(RouteEncoding.encode(Route.Work)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a work item route with a number round-trips for both issues and pull requests`() {
|
||||
val issue = Route.WorkItems(owner = "owner", name = "repo", number = 42, isPullRequest = false)
|
||||
assertEquals(issue, RouteEncoding.decode(RouteEncoding.encode(issue)))
|
||||
|
||||
val pullRequest = Route.WorkItems(owner = "owner", name = "repo", number = 42, isPullRequest = true)
|
||||
assertEquals(pullRequest, RouteEncoding.decode(RouteEncoding.encode(pullRequest)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a work item number that is not numeric decodes to null`() {
|
||||
val encoded = "workItems" + '' + "owner" + '' + "repo" + '' + "issue" + '' + "abc"
|
||||
assertNull(RouteEncoding.decodeOrNull(encoded))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a work item number that is zero or negative decodes to null`() {
|
||||
val zero = "workItems" + '' + "owner" + '' + "repo" + '' + "issue" + '' + "0"
|
||||
val negative = "workItems" + '' + "owner" + '' + "repo" + '' + "issue" + '' + "-1"
|
||||
assertNull(RouteEncoding.decodeOrNull(zero))
|
||||
assertNull(RouteEncoding.decodeOrNull(negative))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restoring an empty stack defaults to Home`() {
|
||||
val restored = RouteStackSaver.restore(emptyList<String>())
|
||||
assertEquals(listOf(Route.Home), restored)
|
||||
}
|
||||
|
||||
@Test
|
||||
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")
|
||||
@ -28,14 +68,14 @@ class RouteEncodingTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognized encoding falls back to Repositories instead of crashing`() {
|
||||
assertEquals(Route.Repositories, RouteEncoding.decode("not-a-known-route-type"))
|
||||
fun `an unrecognized encoding falls back to Home instead of crashing`() {
|
||||
assertEquals(Route.Home, RouteEncoding.decode("not-a-known-route-type"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncated persisted routes fall back instead of indexing missing fields`() {
|
||||
assertEquals(Route.Repositories, RouteEncoding.decode("branchDetail" + '' + "owner"))
|
||||
assertEquals(Route.Repositories, RouteEncoding.decode("workItems" + '' + "owner"))
|
||||
assertEquals(Route.Home, RouteEncoding.decode("branchDetail" + '' + "owner"))
|
||||
assertEquals(Route.Home, RouteEncoding.decode("workItems" + '' + "owner"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user