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.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
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.FavoritePushNotifier
|
||||||
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||||
|
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectType
|
||||||
import com.ubisam.ubgitea.feature.branchdetail.BranchDetailScreen
|
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.CodeBrowserTarget
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel
|
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind
|
import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind
|
||||||
|
import com.ubisam.ubgitea.feature.codebrowser.CodeViewerPreferences
|
||||||
import com.ubisam.ubgitea.feature.history.HistoryTarget
|
import com.ubisam.ubgitea.feature.history.HistoryTarget
|
||||||
import com.ubisam.ubgitea.feature.history.HistoryViewModel
|
import com.ubisam.ubgitea.feature.history.HistoryViewModel
|
||||||
|
import com.ubisam.ubgitea.feature.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.NotificationsScreen
|
||||||
import com.ubisam.ubgitea.feature.notifications.NotificationsViewModel
|
import com.ubisam.ubgitea.feature.notifications.NotificationsViewModel
|
||||||
import com.ubisam.ubgitea.feature.repositories.ReferenceKind
|
import com.ubisam.ubgitea.feature.repositories.ReferenceKind
|
||||||
import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen
|
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.repositories.RepositoryListViewModel
|
||||||
import com.ubisam.ubgitea.feature.settings.SettingsScreen
|
import com.ubisam.ubgitea.feature.settings.SettingsScreen
|
||||||
import com.ubisam.ubgitea.feature.settings.SettingsViewModel
|
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.WorkItemsScreen
|
||||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget
|
import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget
|
||||||
import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel
|
import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel
|
||||||
import com.ubisam.ubgitea.ui.navigation.Route
|
import com.ubisam.ubgitea.ui.navigation.Route
|
||||||
import com.ubisam.ubgitea.ui.navigation.RouteStackSaver
|
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
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@ -61,6 +76,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val settingsViewModel: SettingsViewModel by viewModels {
|
private val settingsViewModel: SettingsViewModel by viewModels {
|
||||||
SettingsViewModel.Factory(application)
|
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
|
// 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
|
// 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 historyState by historyViewModel.state.collectAsState()
|
||||||
val workItemsState by workItemsViewModel.state.collectAsState()
|
val workItemsState by workItemsViewModel.state.collectAsState()
|
||||||
val notificationsState by notificationsViewModel.state.collectAsState()
|
val notificationsState by notificationsViewModel.state.collectAsState()
|
||||||
|
val unreadNotifications by notificationsViewModel.unreadCount.collectAsState()
|
||||||
val settingsState by settingsViewModel.state.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) {
|
var routeStack by rememberSaveable(stateSaver = RouteStackSaver) {
|
||||||
mutableStateOf(listOf<Route>(Route.Repositories))
|
mutableStateOf(listOf<Route>(Route.Home))
|
||||||
}
|
}
|
||||||
fun push(route: Route) { routeStack = routeStack + route }
|
fun push(route: Route) { routeStack = routeStack + route }
|
||||||
fun popToRepositories() { routeStack = listOf(Route.Repositories) }
|
fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Home) } }
|
||||||
fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Repositories) } }
|
fun selectTab(tab: AppTab) { routeStack = listOf(tab.route) }
|
||||||
val currentRoute = routeStack.last()
|
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
|
// 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
|
// 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) {
|
when (val route = currentRoute) {
|
||||||
|
Route.Home -> homeViewModel.open()
|
||||||
Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation.
|
Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation.
|
||||||
|
Route.Work -> workViewModel.open()
|
||||||
is Route.BranchDetail -> {
|
is Route.BranchDetail -> {
|
||||||
val repository = RepositoryCoordinates.of(route.owner, route.name)
|
val repository = RepositoryCoordinates.of(route.owner, route.name)
|
||||||
val ref = RepositoryRef.of(route.ref)
|
val ref = RepositoryRef.of(route.ref)
|
||||||
codeBrowserViewModel.open(CodeBrowserTarget(repository, ref, CodeReferenceKind.valueOf(route.refKind)))
|
codeBrowserViewModel.open(CodeBrowserTarget(repository, ref, CodeReferenceKind.valueOf(route.refKind)))
|
||||||
historyViewModel.open(HistoryTarget(repository, ref))
|
historyViewModel.open(HistoryTarget(repository, ref))
|
||||||
}
|
}
|
||||||
is Route.WorkItems -> workItemsViewModel.open(
|
is Route.WorkItems -> {
|
||||||
WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name)),
|
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.Notifications -> notificationsViewModel.open()
|
||||||
Route.Settings -> Unit // SettingsViewModel reads its (synchronous) store at construction.
|
Route.Settings -> Unit // SettingsViewModel reads its (synchronous) store at construction.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun authenticationExpired() {
|
fun closeFeatures() {
|
||||||
codeBrowserViewModel.close()
|
codeBrowserViewModel.close()
|
||||||
historyViewModel.close()
|
historyViewModel.close()
|
||||||
workItemsViewModel.close()
|
workItemsViewModel.close()
|
||||||
notificationsViewModel.close()
|
notificationsViewModel.close()
|
||||||
popToRepositories()
|
notificationsViewModel.clearUnreadCount()
|
||||||
|
homeViewModel.close()
|
||||||
|
workViewModel.close()
|
||||||
|
}
|
||||||
|
fun signOut() {
|
||||||
|
closeFeatures()
|
||||||
|
selectTab(AppTab.HOME)
|
||||||
viewModel.logout()
|
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 {
|
UBGiteaTheme {
|
||||||
when (currentRoute) {
|
AppShell(
|
||||||
Route.Settings -> SettingsScreen(
|
selectedTab = currentTab,
|
||||||
state = settingsState,
|
showNavigation = showNavigation,
|
||||||
onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled,
|
unreadNotifications = unreadNotifications,
|
||||||
onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled,
|
onTabSelected = { tab ->
|
||||||
onBack = { pop() },
|
if (currentRoute is Route.BranchDetail) {
|
||||||
)
|
|
||||||
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 = {
|
|
||||||
codeBrowserViewModel.close()
|
codeBrowserViewModel.close()
|
||||||
historyViewModel.close()
|
historyViewModel.close()
|
||||||
pop()
|
}
|
||||||
},
|
if (currentRoute is Route.WorkItems) workItemsViewModel.close()
|
||||||
onAuthenticationExpired = ::authenticationExpired,
|
selectTab(tab)
|
||||||
)
|
},
|
||||||
Route.Repositories -> RepositoryListScreen(
|
) { isWide ->
|
||||||
state = state,
|
// Back on any tab but Home goes Home; back on Home leaves the app.
|
||||||
onSignIn = viewModel::signIn,
|
BackHandler(enabled = sessionReady && currentRoute.isTab && currentRoute != Route.Home) {
|
||||||
onCancelSignIn = viewModel::cancelSignIn,
|
selectTab(AppTab.HOME)
|
||||||
onRetry = viewModel::retry,
|
}
|
||||||
onLogout = viewModel::logout,
|
val repositoryList: @Composable () -> Unit = {
|
||||||
onRepositorySelected = viewModel::selectRepository,
|
RepositoryListScreen(
|
||||||
onReferenceSelected = { repository, name, kind ->
|
state = state,
|
||||||
viewModel.selectReference(name, kind)
|
instanceHost = instanceHost,
|
||||||
val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG
|
onSignIn = viewModel::signIn,
|
||||||
push(
|
onCancelSignIn = viewModel::cancelSignIn,
|
||||||
Route.BranchDetail(
|
onRetry = viewModel::retry,
|
||||||
owner = repository.coordinates.owner,
|
onLogout = ::signOut,
|
||||||
name = repository.coordinates.name,
|
onRepositorySelected = viewModel::selectRepository,
|
||||||
refKind = refKind.name,
|
onReferenceSelected = { repository, name, kind ->
|
||||||
ref = name,
|
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,
|
is Route.BranchDetail -> BranchDetailScreen(
|
||||||
onBackToRepositories = viewModel::backToRepositories,
|
owner = route.owner,
|
||||||
onNextPage = viewModel::nextPage,
|
name = route.name,
|
||||||
onPreviousPage = viewModel::previousPage,
|
ref = route.ref,
|
||||||
onRefreshRepositories = viewModel::refreshRepositories,
|
codeBrowserState = codeBrowserState,
|
||||||
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
|
codeViewerPreferences = codeViewerPreferences,
|
||||||
onToggleFavorite = viewModel::toggleFavorite,
|
onEntrySelected = codeBrowserViewModel::openEntry,
|
||||||
onShowAllRepositories = viewModel::showAllRepositories,
|
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
|
||||||
onShowFavorites = viewModel::showFavorites,
|
onRetryCodeBrowser = codeBrowserViewModel::retry,
|
||||||
onViewWorkItems = { repository ->
|
onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) leaveBranchDetail() },
|
||||||
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
|
onNavigateImage = codeBrowserViewModel::navigateImage,
|
||||||
},
|
onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled,
|
||||||
onSelectOrganization = viewModel::selectOrganization,
|
onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp,
|
||||||
onViewNotifications = { push(Route.Notifications) },
|
onOpenSibling = codeBrowserViewModel::openSibling,
|
||||||
onViewSettings = { push(Route.Settings) },
|
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) {
|
LaunchedEffect(viewModel) {
|
||||||
viewModel.browserRequests.collect(::openAuthorizationTab)
|
viewModel.browserRequests.collect(::openAuthorizationTab)
|
||||||
@ -293,3 +447,28 @@ class MainActivity : ComponentActivity() {
|
|||||||
pendingFavoritePushRoute = Route.BranchDetail(owner, name, CodeReferenceKind.BRANCH.name, ref)
|
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 setReactiveAuthRetryEnabled(enabled: Boolean)
|
||||||
fun isFavoritePushNotificationsEnabled(): Boolean
|
fun isFavoritePushNotificationsEnabled(): Boolean
|
||||||
fun setFavoritePushNotificationsEnabled(enabled: 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 {
|
class SharedPreferencesAppSettingsStore(context: Context) : AppSettingsStore {
|
||||||
@ -36,7 +49,24 @@ class SharedPreferencesAppSettingsStore(context: Context) : AppSettingsStore {
|
|||||||
preferences.edit().putBoolean(KEY_FAVORITE_PUSH_NOTIFICATIONS, enabled).apply()
|
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 {
|
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 PREFERENCES_NAME = "ubgitea_app_settings"
|
||||||
const val KEY_REACTIVE_AUTH_RETRY = "reactive_auth_retry_enabled"
|
const val KEY_REACTIVE_AUTH_RETRY = "reactive_auth_retry_enabled"
|
||||||
const val KEY_FAVORITE_PUSH_NOTIFICATIONS = "favorite_push_notifications_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)
|
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 {
|
private fun mapNotification(dto: NotificationDto): NotificationSummary {
|
||||||
val subject = dto.subject ?: throw ApiFailure.InvalidResponse()
|
val subject = dto.subject ?: throw ApiFailure.InvalidResponse()
|
||||||
val repositoryOwner = dto.repository?.owner?.login.required()
|
val repositoryOwner = dto.repository?.owner?.login.required()
|
||||||
@ -137,6 +146,7 @@ class GiteaNotificationsApi(
|
|||||||
private fun Int.safeNextPage(): Int =
|
private fun Int.safeNextPage(): Int =
|
||||||
if (this == Int.MAX_VALUE) throw ApiFailure.InvalidResponse() else this + 1
|
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 OwnerDto(val login: String?)
|
||||||
private data class RepositoryDto(val owner: OwnerDto?, val name: 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?)
|
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
|
* 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
|
* 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
|
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>> {
|
private fun splitIntoFileSections(patch: String): List<List<String>> {
|
||||||
val sections = mutableListOf<MutableList<String>>()
|
val sections = mutableListOf<MutableList<String>>()
|
||||||
for (line in patch.lineSequence()) {
|
for (line in patch.lineSequence()) {
|
||||||
|
|||||||
@ -8,4 +8,7 @@ import com.ubisam.ubgitea.domain.model.Page
|
|||||||
*/
|
*/
|
||||||
interface NotificationsRepository {
|
interface NotificationsRepository {
|
||||||
suspend fun listNotifications(page: Int, pageSize: Int, unreadOnly: Boolean): Page<NotificationSummary>
|
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.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
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.Tab
|
||||||
import androidx.compose.material3.TabRow
|
import androidx.compose.material3.TabRow
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -12,33 +13,49 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import com.ubisam.ubgitea.R
|
import com.ubisam.ubgitea.R
|
||||||
import com.ubisam.ubgitea.domain.history.FileChange
|
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.RepositoryPath
|
||||||
import com.ubisam.ubgitea.domain.model.TreeEntry
|
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.CodeBrowserScreen
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserUiState
|
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.HistoryScreen
|
||||||
import com.ubisam.ubgitea.feature.history.HistoryUiState
|
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
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One branch/tag/commit ref, shown as two swipeable tabs — Files (the code browser, default) and
|
* One branch/tag/commit ref, shown as two swipeable tabs — Files (the directory listing, default)
|
||||||
* Commit — over the same ref. Each tab keeps its own existing ViewModel and internal drill-down
|
* and Commit — over the same ref. Each tab keeps its own ViewModel and its own 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
|
* The tabs only ever hold lists. A file, and a file's diff, open full screen in place of the
|
||||||
* to unwind (see `onCodeBrowserBack`/`onHistoryBack`).
|
* 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
|
@Composable
|
||||||
fun BranchDetailScreen(
|
fun BranchDetailScreen(
|
||||||
|
owner: String,
|
||||||
|
name: String,
|
||||||
|
ref: String,
|
||||||
codeBrowserState: CodeBrowserUiState,
|
codeBrowserState: CodeBrowserUiState,
|
||||||
|
codeViewerPreferences: CodeViewerPreferences,
|
||||||
onEntrySelected: (TreeEntry) -> Unit,
|
onEntrySelected: (TreeEntry) -> Unit,
|
||||||
onBreadcrumbSelected: (RepositoryPath) -> Unit,
|
onBreadcrumbSelected: (RepositoryPath) -> Unit,
|
||||||
onRetryCodeBrowser: () -> Unit,
|
onRetryCodeBrowser: () -> Unit,
|
||||||
onCodeBrowserBack: () -> Unit,
|
onCodeBrowserBack: () -> Unit,
|
||||||
onNavigateImage: (Int) -> Unit,
|
onNavigateImage: (Int) -> Unit,
|
||||||
|
onSetCodeWordWrap: (Boolean) -> Unit,
|
||||||
|
onSetCodeFontSize: (Int) -> Unit,
|
||||||
|
onOpenSibling: (RepositoryPath, String?) -> Unit,
|
||||||
|
onLoadCounterpart: () -> Unit,
|
||||||
historyState: HistoryUiState,
|
historyState: HistoryUiState,
|
||||||
onCommitSelected: (CommitSummary) -> Unit,
|
onCommitSelected: (CommitSummary) -> Unit,
|
||||||
onHistoryNextPage: () -> Unit,
|
onHistoryNextPage: () -> Unit,
|
||||||
@ -47,73 +64,88 @@ fun BranchDetailScreen(
|
|||||||
onRetryHistoryDetail: () -> Unit,
|
onRetryHistoryDetail: () -> Unit,
|
||||||
onRetryPatch: () -> Unit,
|
onRetryPatch: () -> Unit,
|
||||||
onFileSelected: (FileChange) -> Unit,
|
onFileSelected: (FileChange) -> Unit,
|
||||||
|
onShowAdjacentFile: (Int) -> Unit,
|
||||||
onClearFileSelection: () -> Unit,
|
onClearFileSelection: () -> Unit,
|
||||||
onHistoryBackToList: () -> Unit,
|
onHistoryBackToList: () -> Unit,
|
||||||
onHistoryBack: () -> Unit,
|
onHistoryBack: () -> Unit,
|
||||||
|
onViewWorkItems: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
onAuthenticationExpired: () -> Unit,
|
onAuthenticationExpired: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val pagerState = rememberPagerState(pageCount = { 2 })
|
val pagerState = rememberPagerState(pageCount = { 2 })
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
// A page showing horizontally-gestured content — scrollable source text, a diff's before/after
|
when {
|
||||||
// panels, an image gallery's swipe-to-navigate, or a CSV table's horizontal scroll — has its
|
pagerState.currentPage == FILES_PAGE && codeBrowserState.isFileLevel -> FileViewerScreen(
|
||||||
// own left/right drag handling that directly conflicts with the pager's own — both are
|
state = codeBrowserState,
|
||||||
// horizontal, so a drag meant for that content was instead partly consumed by the pager, which
|
preferences = codeViewerPreferences,
|
||||||
// is what made scrolling code feel jittery and occasionally scroll text out of view entirely.
|
onBack = onCodeBrowserBack,
|
||||||
// Swiping between tabs only makes sense one level up from any of that anyway (Files' folder
|
onRetry = onRetryCodeBrowser,
|
||||||
// listing, History's changed-file list), so the pager's own gesture is disabled while either
|
onAuthenticationExpired = onAuthenticationExpired,
|
||||||
// tab is showing one of these, and re-enabled once back at that list.
|
onNavigateImage = onNavigateImage,
|
||||||
val currentPageShowsScrollableCode = when (pagerState.currentPage) {
|
onSetWordWrap = onSetCodeWordWrap,
|
||||||
0 -> codeBrowserState is CodeBrowserUiState.File &&
|
onSetFontSize = onSetCodeFontSize,
|
||||||
(codeBrowserState.content is ContentClassification.Text ||
|
onOpenSibling = onOpenSibling,
|
||||||
codeBrowserState.content is ContentClassification.Image ||
|
onOpenEntry = onEntrySelected,
|
||||||
codeBrowserState.content is ContentClassification.Csv ||
|
onLoadCounterpart = onLoadCounterpart,
|
||||||
codeBrowserState.content is ContentClassification.Pdf)
|
modifier = modifier,
|
||||||
else -> historyState is HistoryUiState.CommitDetailContent && historyState.selectedFile != null
|
)
|
||||||
}
|
pagerState.currentPage == COMMIT_PAGE && historyState is HistoryUiState.CommitDetailContent && historyState.showsFileDiff ->
|
||||||
Column(modifier.fillMaxSize().statusBarsPadding()) {
|
FileDiffScreen(
|
||||||
TabRow(selectedTabIndex = pagerState.currentPage) {
|
state = historyState,
|
||||||
Tab(
|
fontSizeSp = codeViewerPreferences.fontSizeSp,
|
||||||
selected = pagerState.currentPage == 0,
|
onShowAdjacentFile = onShowAdjacentFile,
|
||||||
onClick = { scope.launch { pagerState.animateScrollToPage(0) } },
|
onClose = onClearFileSelection,
|
||||||
text = { Text(stringResource(R.string.branchdetail_tab_files)) },
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
Tab(
|
else -> Column(modifier.fillMaxSize()) {
|
||||||
selected = pagerState.currentPage == 1,
|
ScreenTopBar(title = name, subtitle = stringResource(R.string.branchdetail_subtitle, owner, ref), onBack = onClose) {
|
||||||
onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
|
IconButton(onClick = onViewWorkItems) {
|
||||||
text = { Text(stringResource(R.string.branchdetail_tab_history)) },
|
UBIcon(R.drawable.ic_ub_pull_request, stringResource(R.string.repositories_work_items))
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
HorizontalPager(
|
TabRow(selectedTabIndex = pagerState.currentPage, containerColor = MaterialTheme.colorScheme.surface) {
|
||||||
state = pagerState,
|
listOf(R.string.branchdetail_tab_files, R.string.branchdetail_tab_history).forEachIndexed { page, label ->
|
||||||
userScrollEnabled = !currentPageShowsScrollableCode,
|
val selected = pagerState.currentPage == page
|
||||||
modifier = Modifier.weight(1f),
|
Tab(
|
||||||
) { page ->
|
selected = selected,
|
||||||
when (page) {
|
onClick = { scope.launch { pagerState.animateScrollToPage(page) } },
|
||||||
0 -> CodeBrowserScreen(
|
text = { Text(stringResource(label), fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium) },
|
||||||
state = codeBrowserState,
|
)
|
||||||
onEntrySelected = onEntrySelected,
|
}
|
||||||
onBreadcrumbSelected = onBreadcrumbSelected,
|
}
|
||||||
onRetry = onRetryCodeBrowser,
|
HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page ->
|
||||||
onBack = onCodeBrowserBack,
|
when (page) {
|
||||||
onAuthenticationExpired = onAuthenticationExpired,
|
FILES_PAGE -> CodeBrowserScreen(
|
||||||
onNavigateImage = onNavigateImage,
|
state = codeBrowserState,
|
||||||
)
|
onEntrySelected = onEntrySelected,
|
||||||
else -> HistoryScreen(
|
onBreadcrumbSelected = onBreadcrumbSelected,
|
||||||
state = historyState,
|
onRetry = onRetryCodeBrowser,
|
||||||
onCommitSelected = onCommitSelected,
|
onBack = onCodeBrowserBack,
|
||||||
onNextPage = onHistoryNextPage,
|
onAuthenticationExpired = onAuthenticationExpired,
|
||||||
onPreviousPage = onHistoryPreviousPage,
|
)
|
||||||
onRetryList = onRetryHistoryList,
|
else -> HistoryScreen(
|
||||||
onRetryDetail = onRetryHistoryDetail,
|
state = historyState,
|
||||||
onRetryPatch = onRetryPatch,
|
onCommitSelected = onCommitSelected,
|
||||||
onFileSelected = onFileSelected,
|
onNextPage = onHistoryNextPage,
|
||||||
onClearFileSelection = onClearFileSelection,
|
onPreviousPage = onHistoryPreviousPage,
|
||||||
onBackToList = onHistoryBackToList,
|
onRetryList = onRetryHistoryList,
|
||||||
onBack = onHistoryBack,
|
onRetryDetail = onRetryHistoryDetail,
|
||||||
onAuthenticationExpired = onAuthenticationExpired,
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.offset
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
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.CircularProgressIndicator
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.input.pointer.positionChanged
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.IntOffset
|
||||||
import androidx.compose.ui.unit.IntSize
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.ubisam.ubgitea.R
|
import com.ubisam.ubgitea.R
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||||
import com.ubisam.ubgitea.domain.model.SourceContent
|
import com.ubisam.ubgitea.domain.model.SourceContent
|
||||||
import com.ubisam.ubgitea.domain.model.TreeEntry
|
import com.ubisam.ubgitea.domain.model.TreeEntry
|
||||||
import com.ubisam.ubgitea.domain.model.TreeEntryType
|
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.ContentClassification
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage
|
import com.ubisam.ubgitea.ui.components.CenteredLoading
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.highlightLines
|
import com.ubisam.ubgitea.ui.components.CenteredMessage
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
|
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
|
import com.ubisam.ubgitea.ui.components.UBIcon
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
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
|
@Composable
|
||||||
fun CodeBrowserScreen(
|
fun CodeBrowserScreen(
|
||||||
state: CodeBrowserUiState,
|
state: CodeBrowserUiState,
|
||||||
@ -104,118 +108,181 @@ fun CodeBrowserScreen(
|
|||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAuthenticationExpired: () -> Unit,
|
onAuthenticationExpired: () -> Unit,
|
||||||
onNavigateImage: (Int) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
Column(modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
when (state) {
|
||||||
when (state) {
|
CodeBrowserUiState.Idle -> Unit
|
||||||
CodeBrowserUiState.Idle -> Unit
|
is CodeBrowserUiState.Loading -> {
|
||||||
is CodeBrowserUiState.Loading -> {
|
Breadcrumbs(state.path, onBreadcrumbSelected)
|
||||||
BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected)
|
CenteredLoading(null)
|
||||||
CenterMessage { CircularProgressIndicator() }
|
}
|
||||||
}
|
is CodeBrowserUiState.Directory -> {
|
||||||
is CodeBrowserUiState.Directory -> {
|
Breadcrumbs(state.path, onBreadcrumbSelected)
|
||||||
BrowserHeader(state.target, state.path, false, onBreadcrumbSelected)
|
if (state.entries.isEmpty()) {
|
||||||
if (state.entries.isEmpty()) {
|
CenteredMessage(stringResource(R.string.codebrowser_directory_empty))
|
||||||
CenterMessage { Text(stringResource(R.string.codebrowser_directory_empty)) }
|
} else {
|
||||||
} else {
|
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 16.dp)) {
|
||||||
LazyColumn(Modifier.weight(1f)) {
|
itemsIndexed(state.entries, key = { _, entry -> entry.path.value }) { index, entry ->
|
||||||
items(state.entries, key = { it.path.value }) { entry ->
|
EntryRow(entry, index == 0, index == state.entries.lastIndex, onEntrySelected)
|
||||||
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)) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
@Composable
|
||||||
private fun BrowserHeader(
|
fun FileViewerScreen(
|
||||||
target: CodeBrowserTarget,
|
state: CodeBrowserUiState,
|
||||||
path: RepositoryPath,
|
preferences: CodeViewerPreferences,
|
||||||
leafIsFile: Boolean,
|
onBack: () -> Unit,
|
||||||
onBreadcrumbSelected: (RepositoryPath) -> 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)) {
|
if (state is CodeBrowserUiState.File && state.content is ContentClassification.Text) {
|
||||||
Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge)
|
CodeViewerScreen(
|
||||||
val refKind = when (target.kind) {
|
state, state.content, preferences, onBack, onSetWordWrap, onSetFontSize, onOpenSibling, onOpenEntry,
|
||||||
CodeReferenceKind.BRANCH -> stringResource(R.string.codebrowser_ref_branch)
|
onLoadCounterpart, modifier,
|
||||||
CodeReferenceKind.TAG -> stringResource(R.string.codebrowser_ref_tag)
|
)
|
||||||
CodeReferenceKind.COMMIT -> stringResource(R.string.codebrowser_ref_commit)
|
return
|
||||||
}
|
}
|
||||||
Text(stringResource(R.string.codebrowser_ref, refKind, target.ref.value))
|
BackHandler(onBack = onBack)
|
||||||
Row(Modifier.horizontalScroll(rememberScrollState()).padding(top = 12.dp)) {
|
val path = when (state) {
|
||||||
val breadcrumbs = CodeBrowserNavigation.breadcrumbs(path)
|
is CodeBrowserUiState.File -> state.path
|
||||||
breadcrumbs.forEachIndexed { index, crumb ->
|
is CodeBrowserUiState.Loading -> state.path
|
||||||
if (index > 0) Text(" / ", modifier = Modifier.padding(horizontal = 2.dp))
|
is CodeBrowserUiState.Error -> state.path
|
||||||
val canNavigate = !(leafIsFile && index == breadcrumbs.lastIndex)
|
is CodeBrowserUiState.UnsupportedEntry -> state.path
|
||||||
Text(
|
else -> RepositoryPath.Root
|
||||||
crumb.value.substringAfterLast('/').ifEmpty { stringResource(R.string.codebrowser_root) },
|
}
|
||||||
color = if (canNavigate) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
Column(modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).statusBarsPadding()) {
|
||||||
modifier = if (canNavigate) {
|
ScreenTopBar(
|
||||||
Modifier.clickable(role = Role.Button) { onBreadcrumbSelected(crumb) }
|
title = path.value.substringAfterLast('/'),
|
||||||
} else {
|
subtitle = CodeBrowserNavigation.parent(path).value.ifEmpty { stringResource(R.string.codebrowser_root) },
|
||||||
Modifier
|
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
|
@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) {
|
val kind = when (entry.type) {
|
||||||
TreeEntryType.DIRECTORY -> stringResource(R.string.codebrowser_entry_directory)
|
TreeEntryType.DIRECTORY -> stringResource(R.string.codebrowser_entry_directory)
|
||||||
TreeEntryType.FILE -> stringResource(R.string.codebrowser_entry_file)
|
TreeEntryType.FILE -> stringResource(R.string.codebrowser_entry_file)
|
||||||
TreeEntryType.SYMLINK -> stringResource(R.string.codebrowser_entry_symlink)
|
TreeEntryType.SYMLINK -> stringResource(R.string.codebrowser_entry_symlink)
|
||||||
TreeEntryType.SUBMODULE -> stringResource(R.string.codebrowser_entry_submodule)
|
TreeEntryType.SUBMODULE -> stringResource(R.string.codebrowser_entry_submodule)
|
||||||
}
|
}
|
||||||
Column(
|
val isDirectory = entry.type == TreeEntryType.DIRECTORY
|
||||||
Modifier.fillMaxWidth().clickable(role = Role.Button) { onSelected(entry) }
|
val shape = RoundedCornerShape(
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
topStart = if (isFirst) 20.dp else 0.dp,
|
||||||
) {
|
topEnd = if (isFirst) 20.dp else 0.dp,
|
||||||
Text(entry.name, style = MaterialTheme.typography.titleMedium)
|
bottomStart = if (isLast) 20.dp else 0.dp,
|
||||||
Text(kind, style = MaterialTheme.typography.bodySmall)
|
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
|
@Composable
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
private fun FileContent(
|
private fun FileContent(
|
||||||
content: ContentClassification,
|
content: ContentClassification,
|
||||||
path: RepositoryPath,
|
path: RepositoryPath,
|
||||||
@ -223,31 +290,8 @@ private fun FileContent(
|
|||||||
onNavigateImage: (Int) -> Unit,
|
onNavigateImage: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
when (content) {
|
when (content) {
|
||||||
is ContentClassification.Text -> {
|
// Text is shown by CodeViewerScreen and never reaches here.
|
||||||
val clipboard = LocalClipboardManager.current
|
is ContentClassification.Text -> Unit
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is ContentClassification.Image -> {
|
is ContentClassification.Image -> {
|
||||||
val index = siblingImagePaths.indexOf(path)
|
val index = siblingImagePaths.indexOf(path)
|
||||||
ImageContent(content, index, siblingImagePaths.size, onNavigateImage)
|
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
|
// 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,
|
// via a retry rather than from a directory listing) — swipe navigation is simply unavailable then,
|
||||||
// same as any single-image folder (totalCount <= 1).
|
// 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.domain.repository.GiteaRepositoryBrowser
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification
|
import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification
|
||||||
import com.ubisam.ubgitea.feature.codebrowser.content.SourceContentClassifier
|
import com.ubisam.ubgitea.feature.codebrowser.content.SourceContentClassifier
|
||||||
|
import com.ubisam.ubgitea.feature.codebrowser.content.counterpartOf
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
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 }
|
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 {
|
sealed interface CodeBrowserUiState {
|
||||||
data object Idle : CodeBrowserUiState
|
data object Idle : CodeBrowserUiState
|
||||||
data class Loading(
|
data class Loading(
|
||||||
@ -62,6 +71,16 @@ sealed interface CodeBrowserUiState {
|
|||||||
// second directory fetch. Empty for anything opened outside a directory listing (e.g. a
|
// 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.
|
// retry from Error) or when the containing folder has no other images.
|
||||||
val siblingImagePaths: List<RepositoryPath> = emptyList(),
|
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
|
) : CodeBrowserUiState
|
||||||
data class UnsupportedEntry(
|
data class UnsupportedEntry(
|
||||||
val target: CodeBrowserTarget,
|
val target: CodeBrowserTarget,
|
||||||
@ -93,6 +112,7 @@ class CodeBrowserViewModel(
|
|||||||
private val mutableState = MutableStateFlow<CodeBrowserUiState>(CodeBrowserUiState.Idle)
|
private val mutableState = MutableStateFlow<CodeBrowserUiState>(CodeBrowserUiState.Idle)
|
||||||
val state: StateFlow<CodeBrowserUiState> = mutableState.asStateFlow()
|
val state: StateFlow<CodeBrowserUiState> = mutableState.asStateFlow()
|
||||||
private var operation: Job? = null
|
private var operation: Job? = null
|
||||||
|
private var counterpartOperation: Job? = null
|
||||||
|
|
||||||
fun open(target: CodeBrowserTarget) = loadDirectory(target, RepositoryPath.Root)
|
fun open(target: CodeBrowserTarget) = loadDirectory(target, RepositoryPath.Root)
|
||||||
|
|
||||||
@ -100,14 +120,7 @@ class CodeBrowserViewModel(
|
|||||||
val target = currentTarget() ?: return
|
val target = currentTarget() ?: return
|
||||||
when (entry.type) {
|
when (entry.type) {
|
||||||
TreeEntryType.DIRECTORY -> loadDirectory(target, entry.path)
|
TreeEntryType.DIRECTORY -> loadDirectory(target, entry.path)
|
||||||
TreeEntryType.FILE -> {
|
TreeEntryType.FILE -> loadFile(target, entry.path, currentSiblingEntries())
|
||||||
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.SYMLINK, TreeEntryType.SUBMODULE -> {
|
TreeEntryType.SYMLINK, TreeEntryType.SUBMODULE -> {
|
||||||
operation?.cancel()
|
operation?.cancel()
|
||||||
mutableState.value = CodeBrowserUiState.UnsupportedEntry(target, entry.path, entry.type)
|
mutableState.value = CodeBrowserUiState.UnsupportedEntry(target, entry.path, entry.type)
|
||||||
@ -124,7 +137,38 @@ class CodeBrowserViewModel(
|
|||||||
if (index == -1) return
|
if (index == -1) return
|
||||||
val newIndex = index + delta
|
val newIndex = index + delta
|
||||||
if (newIndex !in siblings.indices) return
|
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) {
|
fun openBreadcrumb(path: RepositoryPath) {
|
||||||
@ -150,9 +194,16 @@ class CodeBrowserViewModel(
|
|||||||
fun close() {
|
fun close() {
|
||||||
operation?.cancel()
|
operation?.cancel()
|
||||||
operation = null
|
operation = null
|
||||||
|
counterpartOperation?.cancel()
|
||||||
mutableState.value = CodeBrowserUiState.Idle
|
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
|
// 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
|
// 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.
|
// 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(
|
private fun loadFile(
|
||||||
target: CodeBrowserTarget,
|
target: CodeBrowserTarget,
|
||||||
path: RepositoryPath,
|
path: RepositoryPath,
|
||||||
siblingImagePaths: List<RepositoryPath> = emptyList(),
|
siblingEntries: List<TreeEntry> = emptyList(),
|
||||||
|
focusSymbol: String? = null,
|
||||||
) = startOperation {
|
) = startOperation {
|
||||||
|
counterpartOperation?.cancel()
|
||||||
mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = true)
|
mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = true)
|
||||||
try {
|
try {
|
||||||
val classification = withContext(defaultDispatcher) {
|
val classification = withContext(defaultDispatcher) {
|
||||||
val file = browser.getFile(target.repository, target.ref, path)
|
val file = browser.getFile(target.repository, target.ref, path)
|
||||||
classifier.classify(file.content, 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) {
|
} catch (failure: Throwable) {
|
||||||
handleFailure(target, path, failure, isFile = true)
|
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
|
package com.ubisam.ubgitea.feature.codebrowser.content
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||||
|
|
||||||
@ -48,36 +48,48 @@ fun syntaxLanguageForPath(path: RepositoryPath): SyntaxLanguage {
|
|||||||
data class CodeSyntaxColors(
|
data class CodeSyntaxColors(
|
||||||
val text: Color,
|
val text: Color,
|
||||||
val keyword: Color,
|
val keyword: Color,
|
||||||
|
val controlKeyword: Color,
|
||||||
|
val type: Color,
|
||||||
|
val method: Color,
|
||||||
val string: Color,
|
val string: Color,
|
||||||
val comment: Color,
|
val comment: Color,
|
||||||
val number: Color,
|
val number: Color,
|
||||||
val preprocessor: Color,
|
val preprocessor: Color,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Colors are fixed (not derived from the app's own sky-blue Material theme) and only switch
|
// Visual Studio's default colors (TASK-053; the team reads C# and C++ in Visual Studio, so the same
|
||||||
// between two hand-picked sets for light/dark -- source code highlighting has its own well-known
|
// code should look the same here). Token colors are fixed rather than derived from the app's brand
|
||||||
// conventions (keyword blue, string orange/red, comment green) independent of a given app's brand
|
// palette -- source highlighting has its own conventions -- but the plain-text color and the
|
||||||
// color, and reusing brand colors here would read as less familiar, not more polished.
|
// 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
|
@Composable
|
||||||
fun rememberCodeSyntaxColors(): CodeSyntaxColors {
|
fun rememberCodeSyntaxColors(): CodeSyntaxColors {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
return remember(dark) {
|
val text = MaterialTheme.colorScheme.onSurface
|
||||||
|
return remember(dark, text) {
|
||||||
if (dark) {
|
if (dark) {
|
||||||
CodeSyntaxColors(
|
CodeSyntaxColors(
|
||||||
text = Color(0xFFD4D4D4),
|
text = text,
|
||||||
keyword = Color(0xFF569CD6),
|
keyword = Color(0xFF569CD6),
|
||||||
string = Color(0xFFCE9178),
|
controlKeyword = Color(0xFFD8A0DF),
|
||||||
comment = Color(0xFF6A9955),
|
type = Color(0xFF4EC9B0),
|
||||||
|
method = Color(0xFFDCDCAA),
|
||||||
|
string = Color(0xFFD69D85),
|
||||||
|
comment = Color(0xFF57A64A),
|
||||||
number = Color(0xFFB5CEA8),
|
number = Color(0xFFB5CEA8),
|
||||||
preprocessor = Color(0xFF9B9B9B),
|
preprocessor = Color(0xFF9B9B9B),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
CodeSyntaxColors(
|
CodeSyntaxColors(
|
||||||
text = Color(0xFF1F1F1F),
|
text = text,
|
||||||
keyword = Color(0xFF0000FF),
|
keyword = Color(0xFF0000FF),
|
||||||
|
controlKeyword = Color(0xFF8F08C4),
|
||||||
|
type = Color(0xFF1F7A99),
|
||||||
|
method = Color(0xFF74531F),
|
||||||
string = Color(0xFFA31515),
|
string = Color(0xFFA31515),
|
||||||
comment = Color(0xFF008000),
|
comment = Color(0xFF007A00),
|
||||||
number = Color(0xFF098658),
|
number = text,
|
||||||
preprocessor = Color(0xFF6B6B6B),
|
preprocessor = Color(0xFF6B6B6B),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -91,8 +103,21 @@ private data class LangSpec(
|
|||||||
val blockComment: Boolean = false,
|
val blockComment: Boolean = false,
|
||||||
val xmlComment: Boolean = false,
|
val xmlComment: Boolean = false,
|
||||||
val preprocessor: 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(
|
private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
|
||||||
SyntaxLanguage.KOTLIN to LangSpec(
|
SyntaxLanguage.KOTLIN to LangSpec(
|
||||||
keywords = setOf(
|
keywords = setOf(
|
||||||
@ -106,6 +131,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
|
|||||||
),
|
),
|
||||||
lineComment = "//",
|
lineComment = "//",
|
||||||
blockComment = true,
|
blockComment = true,
|
||||||
|
identifiers = true,
|
||||||
),
|
),
|
||||||
SyntaxLanguage.JAVA to LangSpec(
|
SyntaxLanguage.JAVA to LangSpec(
|
||||||
keywords = setOf(
|
keywords = setOf(
|
||||||
@ -118,6 +144,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
|
|||||||
),
|
),
|
||||||
lineComment = "//",
|
lineComment = "//",
|
||||||
blockComment = true,
|
blockComment = true,
|
||||||
|
identifiers = true,
|
||||||
),
|
),
|
||||||
SyntaxLanguage.CSHARP to LangSpec(
|
SyntaxLanguage.CSHARP to LangSpec(
|
||||||
keywords = setOf(
|
keywords = setOf(
|
||||||
@ -132,6 +159,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
|
|||||||
),
|
),
|
||||||
lineComment = "//",
|
lineComment = "//",
|
||||||
blockComment = true,
|
blockComment = true,
|
||||||
|
identifiers = true,
|
||||||
preprocessor = true,
|
preprocessor = true,
|
||||||
),
|
),
|
||||||
SyntaxLanguage.CPP to LangSpec(
|
SyntaxLanguage.CPP to LangSpec(
|
||||||
@ -150,6 +178,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
|
|||||||
),
|
),
|
||||||
lineComment = "//",
|
lineComment = "//",
|
||||||
blockComment = true,
|
blockComment = true,
|
||||||
|
identifiers = true,
|
||||||
preprocessor = true,
|
preprocessor = true,
|
||||||
),
|
),
|
||||||
SyntaxLanguage.JAVASCRIPT to LangSpec(
|
SyntaxLanguage.JAVASCRIPT to LangSpec(
|
||||||
@ -163,6 +192,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
|
|||||||
),
|
),
|
||||||
lineComment = "//",
|
lineComment = "//",
|
||||||
blockComment = true,
|
blockComment = true,
|
||||||
|
identifiers = true,
|
||||||
),
|
),
|
||||||
SyntaxLanguage.PYTHON to LangSpec(
|
SyntaxLanguage.PYTHON to LangSpec(
|
||||||
keywords = setOf(
|
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)) {
|
for (match in pattern.findAll(line, from)) {
|
||||||
val text = match.value
|
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)
|
tokens += SyntaxToken(match.range.first, match.range.last + 1, role)
|
||||||
if (role == TokenRole.COMMENT) open = unterminatedBlockComment(text, spec)
|
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? {
|
private fun unterminatedBlockComment(text: String, spec: LangSpec): BlockComment? {
|
||||||
val block = when {
|
val block = when {
|
||||||
spec.blockComment && text.startsWith(BlockComment.C_STYLE.open) -> BlockComment.C_STYLE
|
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
|
var lastIndex = 0
|
||||||
for (token in tokens) {
|
for (token in tokens) {
|
||||||
if (token.start > lastIndex) append(line.substring(lastIndex, token.start))
|
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))
|
append(line.substring(token.start, token.end))
|
||||||
}
|
}
|
||||||
lastIndex = 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))
|
if (lastIndex < line.length) append(line.substring(lastIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
internal enum class TokenRole(val weight: FontWeight?) {
|
internal enum class TokenRole {
|
||||||
COMMENT(null),
|
COMMENT,
|
||||||
STRING(null),
|
STRING,
|
||||||
NUMBER(null),
|
NUMBER,
|
||||||
KEYWORD(FontWeight.SemiBold),
|
KEYWORD,
|
||||||
PREPROCESSOR(null),
|
CONTROL_KEYWORD,
|
||||||
|
TYPE,
|
||||||
|
METHOD,
|
||||||
|
PREPROCESSOR,
|
||||||
;
|
;
|
||||||
|
|
||||||
fun colorOf(colors: CodeSyntaxColors): Color = when (this) {
|
fun colorOf(colors: CodeSyntaxColors): Color = when (this) {
|
||||||
@ -316,6 +375,9 @@ internal enum class TokenRole(val weight: FontWeight?) {
|
|||||||
STRING -> colors.string
|
STRING -> colors.string
|
||||||
NUMBER -> colors.number
|
NUMBER -> colors.number
|
||||||
KEYWORD -> colors.keyword
|
KEYWORD -> colors.keyword
|
||||||
|
CONTROL_KEYWORD -> colors.controlKeyword
|
||||||
|
TYPE -> colors.type
|
||||||
|
METHOD -> colors.method
|
||||||
PREPROCESSOR -> colors.preprocessor
|
PREPROCESSOR -> colors.preprocessor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -325,7 +387,7 @@ private fun classify(text: String, spec: LangSpec): TokenRole? = when {
|
|||||||
spec.xmlComment && text.startsWith("<!--") -> TokenRole.COMMENT
|
spec.xmlComment && text.startsWith("<!--") -> TokenRole.COMMENT
|
||||||
text.startsWith("\"") || text.startsWith("'") -> TokenRole.STRING
|
text.startsWith("\"") || text.startsWith("'") -> TokenRole.STRING
|
||||||
text.first().isDigit() -> TokenRole.NUMBER
|
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
|
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
|
package com.ubisam.ubgitea.feature.history
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.gestures.Orientation
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.gestures.ScrollableState
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.rememberScrollableState
|
|
||||||
import androidx.compose.foundation.gestures.scrollable
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.padding
|
||||||
import androidx.compose.foundation.layout.width
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
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.HorizontalDivider
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.VerticalDivider
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.State
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
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.font.FontFamily
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import com.ubisam.ubgitea.R
|
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.FileChange
|
||||||
import com.ubisam.ubgitea.domain.history.FileChangeStatus
|
import com.ubisam.ubgitea.domain.history.FileChangeStatus
|
||||||
import com.ubisam.ubgitea.domain.model.CommitSummary
|
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.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 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
|
@Composable
|
||||||
fun HistoryScreen(
|
fun HistoryScreen(
|
||||||
state: HistoryUiState,
|
state: HistoryUiState,
|
||||||
@ -75,131 +71,100 @@ fun HistoryScreen(
|
|||||||
onRetryDetail: () -> Unit,
|
onRetryDetail: () -> Unit,
|
||||||
onRetryPatch: () -> Unit,
|
onRetryPatch: () -> Unit,
|
||||||
onFileSelected: (FileChange) -> Unit,
|
onFileSelected: (FileChange) -> Unit,
|
||||||
onClearFileSelection: () -> Unit,
|
|
||||||
onBackToList: () -> Unit,
|
onBackToList: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAuthenticationExpired: () -> Unit,
|
onAuthenticationExpired: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
Column(modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
when (state) {
|
||||||
when (state) {
|
HistoryUiState.Idle -> Unit
|
||||||
HistoryUiState.Idle -> Unit
|
is HistoryUiState.Loading -> {
|
||||||
is HistoryUiState.Loading -> {
|
BackHandler(onBack = onBack)
|
||||||
BackHandler(onBack = onBack)
|
CenterColumn { EstimatedProgressIndicator() }
|
||||||
Header(state.target)
|
}
|
||||||
CenterMessage { EstimatedProgressIndicator() }
|
is HistoryUiState.CommitList -> {
|
||||||
}
|
BackHandler(onBack = onBack)
|
||||||
is HistoryUiState.CommitList -> {
|
if (state.commits.isEmpty()) {
|
||||||
BackHandler(onBack = onBack)
|
CenteredMessage(stringResource(R.string.history_empty))
|
||||||
Header(state.target)
|
} else {
|
||||||
if (state.commits.isEmpty()) {
|
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp)) {
|
||||||
CenterMessage { Text(stringResource(R.string.history_empty)) }
|
itemsIndexed(state.commits, key = { _, commit -> commit.id }) { index, commit ->
|
||||||
} else {
|
CommitRow(commit, index == 0, index == state.commits.lastIndex) { onCommitSelected(commit) }
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
@Composable
|
||||||
private fun Header(target: HistoryTarget) {
|
private fun CommitRow(commit: CommitSummary, isFirst: Boolean, isLast: Boolean, onSelected: () -> Unit) {
|
||||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
val shape = RoundedCornerShape(
|
||||||
Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge)
|
topStart = if (isFirst) 20.dp else 0.dp,
|
||||||
Text(stringResource(R.string.history_ref, target.ref.value), style = MaterialTheme.typography.bodyMedium)
|
topEnd = if (isFirst) 20.dp else 0.dp,
|
||||||
}
|
bottomStart = if (isLast) 20.dp else 0.dp,
|
||||||
HorizontalDivider()
|
bottomEnd = if (isLast) 20.dp else 0.dp,
|
||||||
}
|
)
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth()) {
|
||||||
@Composable
|
Column {
|
||||||
private fun CommitRow(commit: CommitSummary, onSelected: () -> Unit) {
|
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
Column(
|
Row(
|
||||||
Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected)
|
Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Text(commit.shortId, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
|
Avatar(commit.authorName)
|
||||||
Text(commit.message, style = MaterialTheme.typography.titleMedium, maxLines = 2)
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.history_commit_by_at, commit.authorName, formatInstant(commit.authoredAtEpochMillis)),
|
commit.message.lineSequence().firstOrNull().orEmpty(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
)
|
fontWeight = FontWeight.SemiBold,
|
||||||
}
|
maxLines = 2,
|
||||||
HorizontalDivider()
|
overflow = TextOverflow.Ellipsis,
|
||||||
}
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
// Below the "medium" width breakpoint (Android's own guidance for list-detail layouts, and
|
MonoChip(commit.shortId, icon = R.drawable.ic_ub_commit)
|
||||||
// roughly a phone in any orientation), a selected file replaces the list as its own screen. At
|
Text(
|
||||||
// or above it — a tablet, or a foldable's inner screen unfolded — the file list and the selected
|
stringResource(R.string.history_commit_by_at, commit.authorName, formatRelative(commit.authoredAtEpochMillis)),
|
||||||
// file's diff show side by side at once, since there is room for both.
|
style = MaterialTheme.typography.bodySmall,
|
||||||
private val WideLayoutBreakpoint = 600.dp
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
@Composable
|
overflow = TextOverflow.Ellipsis,
|
||||||
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)) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} 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,
|
onFileSelected: (FileChange) -> Unit,
|
||||||
) {
|
) {
|
||||||
val canOpenFileDiff = state.patch is PatchUiState.Loaded
|
val canOpenFileDiff = state.patch is PatchUiState.Loaded
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||||
item {
|
item(key = "commit") {
|
||||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||||
Text(state.commit.id, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(state.commit.message, style = MaterialTheme.typography.titleMedium)
|
SelectionContainer { Text(state.commit.message.trim(), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) }
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
stringResource(R.string.history_commit_by_at, state.commit.authorName, formatInstant(state.commit.authoredAtEpochMillis)),
|
MonoChip(state.commit.shortId, icon = R.drawable.ic_ub_commit)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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(
|
FileChangeRow(
|
||||||
file,
|
file,
|
||||||
Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
isFirst = index == 0,
|
||||||
|
isLast = index == state.files.lastIndex,
|
||||||
onSelected = if (canOpenFileDiff) ({ onFileSelected(file) }) else null,
|
onSelected = if (canOpenFileDiff) ({ onFileSelected(file) }) else null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { HorizontalDivider(Modifier.padding(vertical = 12.dp)) }
|
item(key = "patch-title") { SectionTitle(stringResource(R.string.history_patch_title), Modifier.padding(top = 8.dp)) }
|
||||||
item { PatchSection(state.patch, onRetryPatch) }
|
item(key = "patch") { PatchSection(state.patch, onRetryPatch) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FileChangeRow(file: FileChange, modifier: Modifier = Modifier, onSelected: (() -> Unit)? = null) {
|
private fun FileChangeRow(file: FileChange, isFirst: Boolean, isLast: Boolean, onSelected: (() -> Unit)?) {
|
||||||
Column(
|
val shape = RoundedCornerShape(
|
||||||
if (onSelected != null) modifier.clickable(role = Role.Button, onClick = onSelected) else modifier,
|
topStart = if (isFirst) 20.dp else 0.dp,
|
||||||
) {
|
topEnd = if (isFirst) 20.dp else 0.dp,
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
bottomStart = if (isLast) 20.dp else 0.dp,
|
||||||
Text(stringResource(file.status.labelResource), style = MaterialTheme.typography.bodySmall)
|
bottomEnd = if (isLast) 20.dp else 0.dp,
|
||||||
Text(file.path, style = MaterialTheme.typography.bodySmall, maxLines = 1)
|
)
|
||||||
}
|
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
|
||||||
file.previousPath?.let {
|
Column {
|
||||||
Text(stringResource(R.string.history_renamed_from, it), style = MaterialTheme.typography.bodySmall)
|
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)
|
||||||
@Composable
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
private fun FileDiffView(state: HistoryUiState.SelectedFileDiff) {
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
Column(Modifier.fillMaxSize()) {
|
) {
|
||||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(state.file.path, style = MaterialTheme.typography.titleMedium)
|
StatusBadge(stringResource(file.status.labelResource), file.status.tone)
|
||||||
if (state.isTruncated) Text(stringResource(R.string.history_patch_truncated), style = MaterialTheme.typography.bodySmall)
|
Text(
|
||||||
}
|
file.path.substringAfterLast('/'),
|
||||||
if (state.rows.isEmpty()) {
|
fontFamily = FontFamily.Monospace,
|
||||||
CenterMessage { Text(stringResource(R.string.history_file_diff_unavailable)) }
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
} else {
|
fontWeight = FontWeight.SemiBold,
|
||||||
// Both lists always have exactly state.rows.size items (a missing side just renders
|
maxLines = 1,
|
||||||
// a blank line for that index), so mirroring first-visible-index/offset between them
|
overflow = TextOverflow.Ellipsis,
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
}
|
Text(
|
||||||
val horizontalOffsetPx = remember(state.rows) { mutableFloatStateOf(0f) }
|
file.path,
|
||||||
// Dragging left must reveal what's further along the line (offset increases), matching
|
fontFamily = FontFamily.Monospace,
|
||||||
// every other horizontally-scrollable surface on Android — verified live to be backwards
|
style = MaterialTheme.typography.bodySmall,
|
||||||
// with a plain "+ delta", so the sign is flipped here instead.
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
val horizontalScrollableState = rememberScrollableState { delta ->
|
maxLines = 1,
|
||||||
val previousOffset = horizontalOffsetPx.floatValue
|
overflow = TextOverflow.StartEllipsis,
|
||||||
val newOffset = (previousOffset - delta).coerceIn(0f, maxHorizontalOffsetPx)
|
)
|
||||||
horizontalOffsetPx.floatValue = newOffset
|
file.previousPath?.let {
|
||||||
previousOffset - newOffset
|
Text(
|
||||||
}
|
stringResource(R.string.history_renamed_from, it),
|
||||||
LaunchedEffect(beforeState, afterState) {
|
style = MaterialTheme.typography.bodySmall,
|
||||||
snapshotFlow { beforeState.firstVisibleItemIndex to beforeState.firstVisibleItemScrollOffset }
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
@Composable
|
||||||
private fun PatchSection(patch: PatchUiState, onRetry: () -> Unit) {
|
private fun PatchSection(patch: PatchUiState, onRetry: () -> Unit) {
|
||||||
when (patch) {
|
when (patch) {
|
||||||
PatchUiState.Loading -> CenterMessage { EstimatedProgressIndicator() }
|
PatchUiState.Loading -> CenterColumn(Modifier.padding(vertical = 24.dp)) { EstimatedProgressIndicator() }
|
||||||
is PatchUiState.Loaded -> PatchText(patch.text, patch.isTruncated)
|
is PatchUiState.Loaded -> ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||||
PatchUiState.Failed -> CenterMessage {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Text(stringResource(R.string.history_patch_failed))
|
if (patch.isTruncated) {
|
||||||
Button(onClick = onRetry) { Text(stringResource(R.string.history_retry)) }
|
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
|
@Composable
|
||||||
private fun PatchText(text: String, isTruncated: Boolean) {
|
internal fun CenterColumn(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||||
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) {
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().padding(24.dp),
|
modifier.fillMaxSize().padding(24.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
content = content,
|
content = content,
|
||||||
@ -459,12 +322,12 @@ private fun EstimatedProgressIndicator() {
|
|||||||
Text(
|
Text(
|
||||||
stringResource(R.string.history_loading_progress, (progress * 100).toInt()),
|
stringResource(R.string.history_loading_progress, (progress * 100).toInt()),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
modifier = Modifier.padding(top = 8.dp).semantics { heading() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val FileChangeStatus.labelResource: Int
|
internal val FileChangeStatus.labelResource: Int
|
||||||
get() = when (this) {
|
@StringRes get() = when (this) {
|
||||||
FileChangeStatus.ADDED -> R.string.history_status_added
|
FileChangeStatus.ADDED -> R.string.history_status_added
|
||||||
FileChangeStatus.MODIFIED -> R.string.history_status_modified
|
FileChangeStatus.MODIFIED -> R.string.history_status_modified
|
||||||
FileChangeStatus.DELETED -> R.string.history_status_deleted
|
FileChangeStatus.DELETED -> R.string.history_status_deleted
|
||||||
@ -472,8 +335,17 @@ private val FileChangeStatus.labelResource: Int
|
|||||||
FileChangeStatus.OTHER -> R.string.history_status_other
|
FileChangeStatus.OTHER -> R.string.history_status_other
|
||||||
}
|
}
|
||||||
|
|
||||||
private val HistoryError.messageResource: Int
|
internal val FileChangeStatus.tone: ActivityTone
|
||||||
get() = when (this) {
|
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.OFFLINE -> R.string.history_offline
|
||||||
HistoryError.AUTHENTICATION_EXPIRED -> R.string.history_auth_expired
|
HistoryError.AUTHENTICATION_EXPIRED -> R.string.history_auth_expired
|
||||||
HistoryError.PERMISSION_DENIED -> R.string.history_permission_denied
|
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.history.GiteaHistoryApi
|
||||||
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
|
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
|
||||||
import com.ubisam.ubgitea.domain.history.CommitDetail
|
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.DiffRow
|
||||||
import com.ubisam.ubgitea.domain.history.FileChange
|
import com.ubisam.ubgitea.domain.history.FileChange
|
||||||
import com.ubisam.ubgitea.domain.history.HistoryRepository
|
import com.ubisam.ubgitea.domain.history.HistoryRepository
|
||||||
@ -75,8 +76,12 @@ sealed interface HistoryUiState {
|
|||||||
val file: FileChange,
|
val file: FileChange,
|
||||||
rows: List<DiffRow>,
|
rows: List<DiffRow>,
|
||||||
val isTruncated: Boolean,
|
val isTruncated: Boolean,
|
||||||
|
entries: List<DiffEntry> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val rows = Collections.unmodifiableList(rows.toList())
|
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(
|
data class Error(
|
||||||
val target: HistoryTarget,
|
val target: HistoryTarget,
|
||||||
@ -152,7 +157,16 @@ class HistoryViewModel(
|
|||||||
val current = mutableState.value as? HistoryUiState.CommitDetailContent ?: return
|
val current = mutableState.value as? HistoryUiState.CommitDetailContent ?: return
|
||||||
val patch = current.patch as? PatchUiState.Loaded ?: return
|
val patch = current.patch as? PatchUiState.Loaded ?: return
|
||||||
val rows = UnifiedDiffParser.parseFile(patch.text, file.path)
|
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() {
|
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
|
package com.ubisam.ubgitea.feature.notifications
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Tab
|
|
||||||
import androidx.compose.material3.TabRow
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
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 androidx.compose.ui.unit.dp
|
||||||
import com.ubisam.ubgitea.R
|
import com.ubisam.ubgitea.R
|
||||||
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectState
|
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectState
|
||||||
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectType
|
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectType
|
||||||
import com.ubisam.ubgitea.domain.notifications.NotificationSummary
|
import com.ubisam.ubgitea.domain.notifications.NotificationSummary
|
||||||
import com.ubisam.ubgitea.ui.SimplePaginationFooter
|
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
|
@Composable
|
||||||
fun NotificationsScreen(
|
fun NotificationsScreen(
|
||||||
@ -44,116 +60,155 @@ fun NotificationsScreen(
|
|||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAuthenticationExpired: () -> Unit,
|
onAuthenticationExpired: () -> Unit,
|
||||||
|
accountName: String?,
|
||||||
|
instanceHost: String,
|
||||||
|
onViewSettings: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
Column(modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
BrandTopBar(accountName, instanceHost, onViewSettings, onLogout)
|
||||||
when (state) {
|
Surface(
|
||||||
NotificationsUiState.Idle -> Unit
|
color = MaterialTheme.colorScheme.surface,
|
||||||
NotificationsUiState.Loading -> {
|
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
||||||
FilterTabs(unreadOnly = true, onShowUnreadOnly, onShowAll)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
CenterMessage { CircularProgressIndicator() }
|
) {
|
||||||
}
|
Column(Modifier.padding(bottom = 12.dp)) {
|
||||||
is NotificationsUiState.Content -> {
|
TabTitle(stringResource(R.string.notifications_title))
|
||||||
FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll)
|
FilterTabs(state.unreadOnlyOrDefault(), onShowUnreadOnly, onShowAll)
|
||||||
LazyColumn(Modifier.weight(1f)) {
|
}
|
||||||
items(state.items, key = NotificationSummary::id) { notification ->
|
}
|
||||||
NotificationRow(notification) { onNotificationSelected(notification) }
|
when (state) {
|
||||||
}
|
NotificationsUiState.Idle, NotificationsUiState.Loading -> CenteredLoading(null)
|
||||||
}
|
is NotificationsUiState.Content -> NotificationsList(state, onNotificationSelected, onNextPage, onPreviousPage)
|
||||||
PageFooter(state.page, state.page > 1, state.hasNextPage, onPreviousPage, onNextPage)
|
is NotificationsUiState.Empty -> CenteredMessage(stringResource(R.string.notifications_empty))
|
||||||
}
|
is NotificationsUiState.Error -> {
|
||||||
is NotificationsUiState.Empty -> {
|
val expired = state.type == NotificationsError.AUTHENTICATION_EXPIRED
|
||||||
FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll)
|
CenteredMessage(
|
||||||
CenterMessage { Text(stringResource(R.string.notifications_empty)) }
|
stringResource(state.type.messageResource),
|
||||||
}
|
action = stringResource(if (expired) R.string.notifications_sign_in_again else R.string.notifications_retry),
|
||||||
is NotificationsUiState.Error -> {
|
onAction = if (expired) onAuthenticationExpired else onRetry,
|
||||||
FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll)
|
)
|
||||||
ErrorMessage(state.type, onRetry, onAuthenticationExpired)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@Composable
|
||||||
private fun FilterTabs(unreadOnly: Boolean, onShowUnreadOnly: () -> Unit, onShowAll: () -> Unit) {
|
private fun FilterTabs(unreadOnly: Boolean, onShowUnreadOnly: () -> Unit, onShowAll: () -> Unit) {
|
||||||
TabRow(selectedTabIndex = if (unreadOnly) 0 else 1) {
|
Row(
|
||||||
Tab(selected = unreadOnly, onClick = onShowUnreadOnly, text = { Text(stringResource(R.string.notifications_filter_unread)) })
|
Modifier.fillMaxWidth().padding(horizontal = 20.dp),
|
||||||
Tab(selected = !unreadOnly, onClick = onShowAll, text = { Text(stringResource(R.string.notifications_filter_all)) })
|
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
|
@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 &&
|
val navigable = notification.subjectNumber != null &&
|
||||||
(notification.subjectType == NotificationSubjectType.ISSUE || notification.subjectType == NotificationSubjectType.PULL_REQUEST)
|
(notification.subjectType == NotificationSubjectType.ISSUE || notification.subjectType == NotificationSubjectType.PULL_REQUEST)
|
||||||
Column(
|
val shape = RoundedCornerShape(
|
||||||
Modifier.fillMaxWidth()
|
topStart = if (isFirst) 20.dp else 0.dp,
|
||||||
.let { if (navigable) it.clickable(role = Role.Button, onClick = onSelected) else it }
|
topEnd = if (isFirst) 20.dp else 0.dp,
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
bottomStart = if (isLast) 20.dp else 0.dp,
|
||||||
) {
|
bottomEnd = if (isLast) 20.dp else 0.dp,
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
)
|
||||||
if (notification.isUnread) {
|
val tone = notification.subjectState.tone()
|
||||||
Text(stringResource(R.string.notifications_unread_label), style = MaterialTheme.typography.bodySmall)
|
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 NotificationSummary.kindIcon(): Int = when (subjectType) {
|
||||||
private fun PageFooter(page: Int, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) {
|
NotificationSubjectType.ISSUE -> R.drawable.ic_ub_issue
|
||||||
SimplePaginationFooter(
|
NotificationSubjectType.PULL_REQUEST ->
|
||||||
previousLabel = stringResource(R.string.notifications_previous_page),
|
if (subjectState == NotificationSubjectState.MERGED) R.drawable.ic_ub_merge else R.drawable.ic_ub_pull_request
|
||||||
pageLabel = stringResource(R.string.notifications_page_number, page),
|
NotificationSubjectType.COMMIT -> R.drawable.ic_ub_commit
|
||||||
nextLabel = stringResource(R.string.notifications_next_page),
|
NotificationSubjectType.REPOSITORY -> R.drawable.ic_ub_repo
|
||||||
hasPrevious = hasPrevious,
|
NotificationSubjectType.OTHER -> R.drawable.ic_ub_more
|
||||||
hasNext = hasNext,
|
|
||||||
onPrevious = onPrevious,
|
|
||||||
onNext = onNext,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
private val NotificationSubjectState.labelResource: Int
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
NotificationSubjectState.OPEN -> R.string.notifications_state_open
|
NotificationSubjectState.OPEN -> R.string.notifications_state_open
|
||||||
@ -162,28 +217,11 @@ private val NotificationSubjectState.labelResource: Int
|
|||||||
NotificationSubjectState.UNKNOWN -> R.string.notifications_state_unknown
|
NotificationSubjectState.UNKNOWN -> R.string.notifications_state_unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same compact colored-Surface badge WorkItemsScreen's issue/PR rows use (see that file's own
|
private fun NotificationSubjectState.tone(): ActivityTone = when (this) {
|
||||||
// StatusBadge doc comment for the full rationale) -- kept as its own copy rather than a shared
|
NotificationSubjectState.OPEN -> ActivityTone.SUCCESS
|
||||||
// component since these are two different feature packages and the two state enums
|
NotificationSubjectState.CLOSED -> ActivityTone.DANGER
|
||||||
// (NotificationSubjectState vs. IssueState/PullRequestState) don't share a type to unify over.
|
NotificationSubjectState.MERGED -> ActivityTone.MERGED
|
||||||
// UNKNOWN -- a real Gitea subject state this client doesn't recognize, not a state to visually
|
NotificationSubjectState.UNKNOWN -> ActivityTone.NEUTRAL
|
||||||
// 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 val NotificationsError.messageResource: Int
|
private val NotificationsError.messageResource: Int
|
||||||
|
|||||||
@ -43,6 +43,21 @@ class NotificationsViewModel(private val repository: NotificationsRepository) :
|
|||||||
val state: StateFlow<NotificationsUiState> = mutableState.asStateFlow()
|
val state: StateFlow<NotificationsUiState> = mutableState.asStateFlow()
|
||||||
private var operation: Job? = null
|
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 open() = loadPage(page = 1, unreadOnly = true)
|
||||||
|
|
||||||
fun showUnreadOnly() = 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
|
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) {
|
private fun loadPage(page: Int, unreadOnly: Boolean) {
|
||||||
operation?.cancel()
|
operation?.cancel()
|
||||||
mutableState.value = NotificationsUiState.Loading
|
mutableState.value = NotificationsUiState.Loading
|
||||||
|
|||||||
@ -1,62 +1,80 @@
|
|||||||
package com.ubisam.ubgitea.feature.repositories
|
package com.ubisam.ubgitea.feature.repositories
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.Image
|
||||||
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.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.horizontalScroll
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.selection.selectable
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.lazy.items
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.TabRow
|
import androidx.compose.material3.TabRow
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.semantics.Role
|
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.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.R
|
||||||
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
|
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
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.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.formatInstant
|
||||||
|
import com.ubisam.ubgitea.ui.formatRelative
|
||||||
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RepositoryListScreen(
|
fun RepositoryListScreen(
|
||||||
state: RepositoryListUiState,
|
state: RepositoryListUiState,
|
||||||
|
instanceHost: String,
|
||||||
onSignIn: () -> Unit,
|
onSignIn: () -> Unit,
|
||||||
onCancelSignIn: () -> Unit,
|
onCancelSignIn: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
@ -74,115 +92,101 @@ fun RepositoryListScreen(
|
|||||||
onShowFavorites: () -> Unit,
|
onShowFavorites: () -> Unit,
|
||||||
onViewWorkItems: (RepositoryRowUiModel) -> Unit,
|
onViewWorkItems: (RepositoryRowUiModel) -> Unit,
|
||||||
onSelectOrganization: (String?) -> Unit,
|
onSelectOrganization: (String?) -> Unit,
|
||||||
onViewNotifications: () -> Unit,
|
|
||||||
onViewSettings: () -> Unit,
|
onViewSettings: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Column(modifier.fillMaxSize()) {
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
topBar = { UBGiteaTopBar() },
|
|
||||||
) { padding ->
|
|
||||||
when (state) {
|
when (state) {
|
||||||
RepositoryListUiState.SignedOut -> MessageState(
|
RepositoryListUiState.SignedOut -> SignedOutState(
|
||||||
stringResource(R.string.repositories_sign_in_description),
|
stringResource(R.string.repositories_sign_in_description),
|
||||||
stringResource(R.string.repositories_sign_in),
|
stringResource(R.string.repositories_sign_in),
|
||||||
onSignIn,
|
onSignIn,
|
||||||
Modifier.padding(padding),
|
|
||||||
)
|
)
|
||||||
RepositoryListUiState.WaitingForBrowser -> MessageState(
|
RepositoryListUiState.WaitingForBrowser -> SignedOutState(
|
||||||
stringResource(R.string.repositories_waiting_browser),
|
stringResource(R.string.repositories_waiting_browser),
|
||||||
stringResource(R.string.repositories_cancel_login),
|
stringResource(R.string.repositories_cancel_login),
|
||||||
onCancelSignIn,
|
onCancelSignIn,
|
||||||
Modifier.padding(padding),
|
|
||||||
)
|
)
|
||||||
RepositoryListUiState.Loading -> LoadingState(
|
RepositoryListUiState.Loading -> {
|
||||||
stringResource(R.string.repositories_loading),
|
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
||||||
Modifier.padding(padding),
|
CenteredLoading(stringResource(R.string.repositories_loading))
|
||||||
)
|
}
|
||||||
is RepositoryListUiState.Empty -> Column(
|
is RepositoryListUiState.Empty -> {
|
||||||
Modifier.padding(padding).fillMaxSize()
|
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
||||||
.modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites),
|
Surface(
|
||||||
) {
|
color = MaterialTheme.colorScheme.surface,
|
||||||
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
|
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
|
// 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
|
// nothing is starred *yet*, while 전체 저장소 (the other tab, right above this
|
||||||
// message) is very likely non-empty. The only sensible primary action here is
|
// message) is very likely non-empty. The only sensible primary action here is
|
||||||
// "go look at 전체 저장소 and star something", never 로그아웃 -- offering logout as
|
// "go look at 전체 저장소 and star something", never 로그아웃.
|
||||||
// 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.
|
|
||||||
if (state.mode == RepositoryListMode.FAVORITES) {
|
if (state.mode == RepositoryListMode.FAVORITES) {
|
||||||
MessageState(
|
CenteredMessage(
|
||||||
stringResource(R.string.repositories_favorites_empty),
|
stringResource(R.string.repositories_favorites_empty),
|
||||||
stringResource(R.string.repositories_favorites_empty_action),
|
action = stringResource(R.string.repositories_favorites_empty_action),
|
||||||
onShowAllRepositories,
|
onAction = onShowAllRepositories,
|
||||||
Modifier.weight(1f),
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
MessageState(
|
CenteredMessage(
|
||||||
stringResource(R.string.repositories_empty_account, state.accountName),
|
stringResource(R.string.repositories_empty_account, state.accountName),
|
||||||
stringResource(R.string.repositories_logout),
|
action = stringResource(R.string.repositories_logout),
|
||||||
onLogout,
|
onAction = onLogout,
|
||||||
Modifier.weight(1f),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is RepositoryListUiState.Error -> {
|
is RepositoryListUiState.Error -> {
|
||||||
|
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
|
||||||
val needsLogin = state.type == RepositoryListError.AUTHENTICATION_EXPIRED ||
|
val needsLogin = state.type == RepositoryListError.AUTHENTICATION_EXPIRED ||
|
||||||
state.type == RepositoryListError.INVALID_CALLBACK
|
state.type == RepositoryListError.INVALID_CALLBACK
|
||||||
val baseMessage = state.accountName?.let {
|
val baseMessage = state.accountName?.let {
|
||||||
stringResource(R.string.repositories_error_account, it, stringResource(state.type.messageResource))
|
stringResource(R.string.repositories_error_account, it, stringResource(state.type.messageResource))
|
||||||
} ?: stringResource(state.type.messageResource)
|
} ?: stringResource(state.type.messageResource)
|
||||||
MessageState(
|
CenteredMessage(
|
||||||
state.debugDetail?.let { "$baseMessage\n\n[debug] $it" } ?: baseMessage,
|
state.debugDetail?.let { "$baseMessage\n\n[debug] $it" } ?: baseMessage,
|
||||||
stringResource(if (needsLogin) R.string.repositories_sign_in else R.string.repositories_retry),
|
action = stringResource(if (needsLogin) R.string.repositories_sign_in else R.string.repositories_retry),
|
||||||
if (needsLogin) onSignIn else onRetry,
|
onAction = if (needsLogin) onSignIn else onRetry,
|
||||||
Modifier.padding(padding),
|
|
||||||
secondaryAction = if (state.canLogout) stringResource(R.string.repositories_logout) else null,
|
secondaryAction = if (state.canLogout) stringResource(R.string.repositories_logout) else null,
|
||||||
onSecondaryAction = onLogout,
|
onSecondaryAction = onLogout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is RepositoryListUiState.Content -> RepositoryContent(
|
is RepositoryListUiState.Content -> {
|
||||||
state,
|
BrandTopBar(state.accountName, instanceHost, onViewSettings, onLogout)
|
||||||
onLogout,
|
RepositoryContent(
|
||||||
onRepositorySelected,
|
state,
|
||||||
onNextPage,
|
onRepositorySelected,
|
||||||
onPreviousPage,
|
onNextPage,
|
||||||
onRefreshRepositories,
|
onPreviousPage,
|
||||||
onRetryLastPushedBranch,
|
onRefreshRepositories,
|
||||||
onShowAllRepositories,
|
onRetryLastPushedBranch,
|
||||||
onShowFavorites,
|
onShowAllRepositories,
|
||||||
onSelectOrganization,
|
onShowFavorites,
|
||||||
onViewNotifications,
|
onSelectOrganization,
|
||||||
onViewSettings,
|
onToggleFavorite,
|
||||||
onToggleFavorite,
|
)
|
||||||
Modifier.padding(padding),
|
}
|
||||||
)
|
|
||||||
is RepositoryListUiState.ReferencesLoading -> {
|
is RepositoryListUiState.ReferencesLoading -> {
|
||||||
BackHandler(onBack = onBackToRepositories)
|
BackHandler(onBack = onBackToRepositories)
|
||||||
LoadingState(
|
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
|
||||||
stringResource(R.string.repositories_references_loading),
|
CenteredLoading(stringResource(R.string.repositories_references_loading))
|
||||||
Modifier.padding(padding),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
is RepositoryListUiState.ReferencesContent -> {
|
is RepositoryListUiState.ReferencesContent -> {
|
||||||
BackHandler(onBack = onBackToRepositories)
|
BackHandler(onBack = onBackToRepositories)
|
||||||
ReferencesContent(
|
ReferencesContent(state, onReferenceSelected, onReferencesRefresh, onViewWorkItems, onBackToRepositories)
|
||||||
state,
|
|
||||||
onReferenceSelected,
|
|
||||||
onReferencesRefresh,
|
|
||||||
onLogout,
|
|
||||||
onViewWorkItems,
|
|
||||||
Modifier.padding(padding),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
is RepositoryListUiState.ReferencesError -> {
|
is RepositoryListUiState.ReferencesError -> {
|
||||||
BackHandler(onBack = onBackToRepositories)
|
BackHandler(onBack = onBackToRepositories)
|
||||||
MessageState(
|
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
|
||||||
|
CenteredMessage(
|
||||||
stringResource(state.type.messageResource),
|
stringResource(state.type.messageResource),
|
||||||
stringResource(R.string.repositories_retry),
|
action = stringResource(R.string.repositories_retry),
|
||||||
onRetry,
|
onAction = onRetry,
|
||||||
Modifier.padding(padding),
|
|
||||||
secondaryAction = stringResource(R.string.repositories_back),
|
secondaryAction = stringResource(R.string.repositories_back),
|
||||||
onSecondaryAction = onBackToRepositories,
|
onSecondaryAction = onBackToRepositories,
|
||||||
)
|
)
|
||||||
@ -192,36 +196,25 @@ fun RepositoryListScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LoadingState(message: String, modifier: Modifier = Modifier) {
|
private fun SignedOutState(message: String, action: String, onAction: () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
modifier.fillMaxSize(),
|
Modifier.fillMaxSize().padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator()
|
Image(
|
||||||
Text(message, modifier = Modifier.padding(top = 16.dp))
|
painter = painterResource(R.drawable.logo_ubisam),
|
||||||
}
|
contentDescription = stringResource(R.string.shell_logo_description),
|
||||||
}
|
modifier = Modifier.height(36.dp),
|
||||||
|
)
|
||||||
@Composable
|
Text(
|
||||||
private fun MessageState(
|
message,
|
||||||
message: String,
|
// Korean breaks between any two characters by default; this keeps words whole.
|
||||||
action: String,
|
style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Heading),
|
||||||
onAction: () -> Unit,
|
textAlign = TextAlign.Center,
|
||||||
modifier: Modifier = Modifier,
|
modifier = Modifier.padding(top = 20.dp),
|
||||||
secondaryAction: String? = null,
|
)
|
||||||
onSecondaryAction: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier.fillMaxSize().padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
Text(message, style = MaterialTheme.typography.bodyLarge)
|
|
||||||
Button(onClick = onAction, modifier = Modifier.padding(top = 20.dp)) { Text(action) }
|
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
|
@Composable
|
||||||
private fun RepositoryContent(
|
private fun RepositoryContent(
|
||||||
state: RepositoryListUiState.Content,
|
state: RepositoryListUiState.Content,
|
||||||
onLogout: () -> Unit,
|
|
||||||
onRepositorySelected: (RepositoryRowUiModel) -> Unit,
|
onRepositorySelected: (RepositoryRowUiModel) -> Unit,
|
||||||
onNextPage: () -> Unit,
|
onNextPage: () -> Unit,
|
||||||
onPreviousPage: () -> Unit,
|
onPreviousPage: () -> Unit,
|
||||||
@ -238,137 +230,58 @@ private fun RepositoryContent(
|
|||||||
onShowAllRepositories: () -> Unit,
|
onShowAllRepositories: () -> Unit,
|
||||||
onShowFavorites: () -> Unit,
|
onShowFavorites: () -> Unit,
|
||||||
onSelectOrganization: (String?) -> Unit,
|
onSelectOrganization: (String?) -> Unit,
|
||||||
onViewNotifications: () -> Unit,
|
|
||||||
onViewSettings: () -> Unit,
|
|
||||||
onToggleFavorite: (RepositoryRowUiModel) -> Unit,
|
onToggleFavorite: (RepositoryRowUiModel) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
PullToRefreshBox(
|
Surface(
|
||||||
isRefreshing = false,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
onRefresh = onRefreshRepositories,
|
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
Column(Modifier.padding(bottom = 12.dp)) {
|
||||||
modifier = Modifier.fillMaxSize().modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites),
|
TabTitle(stringResource(R.string.repositories_list_title))
|
||||||
) {
|
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
|
||||||
item {
|
if (state.mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()) {
|
||||||
Row(
|
OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 =
|
internal fun repositoryLazyListKey(coordinates: RepositoryCoordinates): String =
|
||||||
"${coordinates.owner}/${coordinates.name}"
|
"${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
|
// 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.
|
// horizontal, so this coexists with LazyColumn's own vertical scroll rather than fighting it.
|
||||||
private fun Modifier.modeSwipeGesture(
|
private fun Modifier.modeSwipeGesture(
|
||||||
@ -387,7 +300,7 @@ private fun Modifier.modeSwipeGesture(
|
|||||||
// Content follows the finger, same convention as HorizontalPager elsewhere in this
|
// Content follows the finger, same convention as HorizontalPager elsewhere in this
|
||||||
// app: dragging left (negative totalDragX) brings the next tab in from the right
|
// 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
|
// (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 {
|
when {
|
||||||
totalDragX < -SwipeThresholdPx && mode == RepositoryListMode.ALL -> onShowFavorites()
|
totalDragX < -SwipeThresholdPx && mode == RepositoryListMode.ALL -> onShowFavorites()
|
||||||
totalDragX > SwipeThresholdPx && mode == RepositoryListMode.FAVORITES -> onShowAllRepositories()
|
totalDragX > SwipeThresholdPx && mode == RepositoryListMode.FAVORITES -> onShowAllRepositories()
|
||||||
@ -405,7 +318,7 @@ private fun OrganizationFilterRow(
|
|||||||
onSelect: (String?) -> Unit,
|
onSelect: (String?) -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
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),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
FilterChip(selected == null, { onSelect(null) }, { Text(stringResource(R.string.repositories_organization_all)) })
|
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
|
@Composable
|
||||||
private fun ModeTabs(mode: RepositoryListMode, onShowAllRepositories: () -> Unit, onShowFavorites: () -> Unit) {
|
private fun ModeTabs(mode: RepositoryListMode, onShowAllRepositories: () -> Unit, onShowFavorites: () -> Unit) {
|
||||||
TabRow(selectedTabIndex = if (mode == RepositoryListMode.FAVORITES) 1 else 0) {
|
TabRow(selectedTabIndex = if (mode == RepositoryListMode.FAVORITES) 1 else 0) {
|
||||||
@ -464,33 +348,101 @@ private fun ModeTabs(mode: RepositoryListMode, onShowAllRepositories: () -> Unit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun LastPushedBranchRow(
|
private fun RepositoryRow(
|
||||||
repository: RepositoryRowUiModel,
|
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(
|
LastPushedBranchUiState.Loading -> Text(
|
||||||
stringResource(R.string.repositories_last_pushed_branch_loading),
|
stringResource(R.string.repositories_last_pushed_branch_loading),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
is LastPushedBranchUiState.Loaded -> Text(
|
is LastPushedBranchUiState.Loaded -> MonoChip(state.name, icon = R.drawable.ic_ub_branch)
|
||||||
stringResource(R.string.repositories_last_pushed_branch, branch.name),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
LastPushedBranchUiState.Empty -> Text(
|
LastPushedBranchUiState.Empty -> Text(
|
||||||
stringResource(R.string.repositories_last_pushed_branch_none),
|
stringResource(R.string.repositories_last_pushed_branch_none),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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(
|
Text(
|
||||||
stringResource(R.string.repositories_last_pushed_branch_failed),
|
stringResource(R.string.repositories_last_pushed_branch_failed),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { onRetry(repository.coordinates) },
|
onClick = onRetry,
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||||
) { Text(stringResource(R.string.repositories_retry)) }
|
) { 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
|
// 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
|
// 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
|
// 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,
|
// limitation); on the FAVORITES list, every row is inherently already starred (that's why it's
|
||||||
// every row is inherently already starred (that's why it's showing) and the action un-favorites it —
|
// showing) and the action un-favorites it. Star count itself is always the server's real count,
|
||||||
// removing the row on success rather than toggling this icon's own appearance. Star count itself is
|
// read-only, shown either way.
|
||||||
// always the server's real count, read-only, shown either way.
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FavoriteStar(mode: RepositoryListMode, action: FavoriteActionUiState, starsCount: Int, onClick: () -> Unit) {
|
private fun FavoriteStar(mode: RepositoryListMode, action: FavoriteActionUiState, starsCount: Int, onClick: () -> Unit) {
|
||||||
val inProgress = action == FavoriteActionUiState.InProgress
|
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 filled = if (mode == RepositoryListMode.FAVORITES) !inProgress else action == FavoriteActionUiState.Starred
|
||||||
val color = when {
|
val color = when {
|
||||||
failed -> MaterialTheme.colorScheme.error
|
failed -> MaterialTheme.colorScheme.error
|
||||||
filled -> MaterialTheme.colorScheme.primary
|
filled -> UBGiteaTheme.colors.brand
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
}
|
||||||
Row(
|
val isUnfavoriteAction = mode == RepositoryListMode.FAVORITES || filled
|
||||||
Modifier.clickable(role = Role.Button, enabled = !inProgress) { onClick() }.padding(4.dp),
|
val description = stringResource(if (isUnfavoriteAction) R.string.repositories_unfavorite else R.string.repositories_favorite)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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)
|
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, modifier = Modifier.padding(start = 4.dp))
|
Text(starsCount.toString(), color = color, style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -527,92 +486,105 @@ private fun ReferencesContent(
|
|||||||
state: RepositoryListUiState.ReferencesContent,
|
state: RepositoryListUiState.ReferencesContent,
|
||||||
onReferenceSelected: (RepositoryRowUiModel, String, ReferenceKind) -> Unit,
|
onReferenceSelected: (RepositoryRowUiModel, String, ReferenceKind) -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
|
||||||
onViewWorkItems: (RepositoryRowUiModel) -> Unit,
|
onViewWorkItems: (RepositoryRowUiModel) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
onBackToRepositories: () -> Unit,
|
||||||
) {
|
) {
|
||||||
PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = modifier.fillMaxSize()) {
|
ScreenTopBar(
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
title = state.repository.name,
|
||||||
item {
|
onBack = onBackToRepositories,
|
||||||
Row(
|
subtitle = state.repository.owner,
|
||||||
Modifier.fillMaxWidth().padding(16.dp),
|
actions = {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
IconButton(onClick = { onViewWorkItems(state.repository) }) {
|
||||||
) {
|
UBIcon(R.drawable.ic_ub_pull_request, stringResource(R.string.repositories_work_items))
|
||||||
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)) }
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
item {
|
)
|
||||||
Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
|
||||||
OutlinedButton(onClick = { onViewWorkItems(state.repository) }) {
|
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||||
Text(stringResource(R.string.repositories_work_items))
|
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
|
@Composable
|
||||||
private fun ReferenceRow(reference: ReferenceRowUiModel, onSelected: (String, ReferenceKind) -> Unit) {
|
private fun EmptyReferenceMessage(message: String) {
|
||||||
val contentColor = if (reference.isSelected) MaterialTheme.colorScheme.primary else Color.Unspecified
|
Text(
|
||||||
Column(
|
message,
|
||||||
Modifier
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
.fillMaxWidth()
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
.background(if (reference.isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||||
.selectable(
|
)
|
||||||
selected = reference.isSelected,
|
}
|
||||||
role = Role.RadioButton,
|
|
||||||
onClick = { onSelected(reference.name, reference.kind) },
|
@Composable
|
||||||
)
|
private fun ReferenceRow(reference: ReferenceRowUiModel, isFirst: Boolean, isLast: Boolean, onSelected: (String, ReferenceKind) -> Unit) {
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
val shape = RoundedCornerShape(
|
||||||
) {
|
topStart = if (isFirst) 20.dp else 0.dp,
|
||||||
Text(reference.name, style = MaterialTheme.typography.titleMedium, color = contentColor)
|
topEnd = if (isFirst) 20.dp else 0.dp,
|
||||||
Text(reference.commitId.take(12), style = MaterialTheme.typography.bodySmall, color = contentColor)
|
bottomStart = if (isLast) 20.dp else 0.dp,
|
||||||
reference.commitAtEpochMillis?.let {
|
bottomEnd = if (isLast) 20.dp else 0.dp,
|
||||||
Text(
|
)
|
||||||
stringResource(R.string.repositories_commit_at, formatInstant(it)),
|
val containerColor = if (reference.isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface
|
||||||
style = MaterialTheme.typography.bodySmall,
|
val contentColor = if (reference.isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
|
||||||
color = contentColor,
|
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
|
private val RepositoryListError.messageResource: Int
|
||||||
|
|||||||
@ -100,6 +100,17 @@ sealed interface RepositoryListUiState {
|
|||||||
) : 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 {
|
sealed interface LastPushedBranchUiState {
|
||||||
data object Loading : LastPushedBranchUiState
|
data object Loading : LastPushedBranchUiState
|
||||||
data class Loaded(val name: String) : LastPushedBranchUiState
|
data class Loaded(val name: String) : LastPushedBranchUiState
|
||||||
|
|||||||
@ -1,62 +1,141 @@
|
|||||||
package com.ubisam.ubgitea.feature.settings
|
package com.ubisam.ubgitea.feature.settings
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.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.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
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 androidx.compose.ui.unit.dp
|
||||||
import com.ubisam.ubgitea.R
|
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
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
state: SettingsUiState,
|
state: SettingsUiState,
|
||||||
onSetReactiveAuthRetryEnabled: (Boolean) -> Unit,
|
onSetReactiveAuthRetryEnabled: (Boolean) -> Unit,
|
||||||
onSetFavoritePushNotificationsEnabled: (Boolean) -> Unit,
|
onSetFavoritePushNotificationsEnabled: (Boolean) -> Unit,
|
||||||
|
accountName: String?,
|
||||||
|
instanceHost: String,
|
||||||
|
onSetCodeWordWrapEnabled: (Boolean) -> Unit,
|
||||||
|
onSetCodeFontSizeSp: (Int) -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
Column(modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
|
ScreenTopBar(title = stringResource(R.string.settings_title), onBack = onBack)
|
||||||
Text(stringResource(R.string.settings_title), style = MaterialTheme.typography.headlineSmall)
|
Column(
|
||||||
SettingRow(
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(vertical = 16.dp),
|
||||||
title = stringResource(R.string.settings_reactive_auth_retry_title),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
description = stringResource(R.string.settings_reactive_auth_retry_description),
|
) {
|
||||||
checked = state.reactiveAuthRetryEnabled,
|
SectionTitle(stringResource(R.string.settings_section_account))
|
||||||
onCheckedChange = onSetReactiveAuthRetryEnabled,
|
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||||
)
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
HorizontalDivider()
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
SettingRow(
|
Avatar(accountName.orEmpty(), size = 40.dp)
|
||||||
title = stringResource(R.string.settings_favorite_push_notifications_title),
|
Column(Modifier.weight(1f)) {
|
||||||
description = stringResource(R.string.settings_favorite_push_notifications_description),
|
Text(
|
||||||
checked = state.favoritePushNotificationsEnabled,
|
accountName ?: stringResource(R.string.settings_account_unknown),
|
||||||
onCheckedChange = onSetFavoritePushNotificationsEnabled,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
fontWeight = FontWeight.SemiBold,
|
||||||
HorizontalDivider()
|
)
|
||||||
|
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
|
@Composable
|
||||||
private fun SettingRow(
|
private fun SwitchRow(
|
||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
checked: Boolean,
|
checked: Boolean,
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(vertical = 16.dp),
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f).padding(end = 12.dp)) {
|
Column(Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
data class SettingsUiState(
|
data class SettingsUiState(
|
||||||
val reactiveAuthRetryEnabled: Boolean,
|
val reactiveAuthRetryEnabled: Boolean,
|
||||||
val favoritePushNotificationsEnabled: 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(
|
SettingsUiState(
|
||||||
reactiveAuthRetryEnabled = store.isReactiveAuthRetryEnabled(),
|
reactiveAuthRetryEnabled = store.isReactiveAuthRetryEnabled(),
|
||||||
favoritePushNotificationsEnabled = store.isFavoritePushNotificationsEnabled(),
|
favoritePushNotificationsEnabled = store.isFavoritePushNotificationsEnabled(),
|
||||||
|
codeWordWrapEnabled = store.isCodeWordWrapEnabled(),
|
||||||
|
codeFontSizeSp = store.codeFontSizeSp(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val state: StateFlow<SettingsUiState> = mutableState.asStateFlow()
|
val state: StateFlow<SettingsUiState> = mutableState.asStateFlow()
|
||||||
@ -38,6 +42,17 @@ class SettingsViewModel(private val store: AppSettingsStore) : ViewModel() {
|
|||||||
mutableState.value = mutableState.value.copy(favoritePushNotificationsEnabled = enabled)
|
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 {
|
class Factory(private val application: Application) : ViewModelProvider.Factory {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
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.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.TabRow
|
import androidx.compose.material3.TabRow
|
||||||
@ -39,11 +36,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
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.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
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 com.ubisam.ubgitea.R
|
import com.ubisam.ubgitea.R
|
||||||
import com.ubisam.ubgitea.domain.workitems.CommentSummary
|
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.domain.workitems.WorkItemAuthor
|
||||||
import com.ubisam.ubgitea.ui.PaginationButtonHeight
|
import com.ubisam.ubgitea.ui.PaginationButtonHeight
|
||||||
import com.ubisam.ubgitea.ui.SimplePaginationFooter
|
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
|
@Composable
|
||||||
fun WorkItemsScreen(
|
fun WorkItemsScreen(
|
||||||
@ -74,70 +87,117 @@ fun WorkItemsScreen(
|
|||||||
onAuthenticationExpired: () -> Unit,
|
onAuthenticationExpired: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
Column(modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
when (state) {
|
||||||
when (state) {
|
WorkItemsUiState.Idle -> Unit
|
||||||
WorkItemsUiState.Idle -> Unit
|
is WorkItemsUiState.Loading -> {
|
||||||
is WorkItemsUiState.Loading -> {
|
BackHandler(onBack = onBack)
|
||||||
BackHandler(onBack = onBack)
|
ListHeader(state.target, onBack)
|
||||||
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
|
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
|
||||||
CenterMessage { CircularProgressIndicator() }
|
CenteredLoading(null)
|
||||||
}
|
}
|
||||||
is WorkItemsUiState.IssueList -> {
|
is WorkItemsUiState.IssueList -> {
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
ModeTabs(WorkItemsTab.ISSUES, onShowIssues, onShowPullRequests)
|
ListHeader(state.target, onBack)
|
||||||
LazyColumn(Modifier.weight(1f)) {
|
ModeTabs(WorkItemsTab.ISSUES, onShowIssues, onShowPullRequests)
|
||||||
items(state.items, key = IssueSummary::number) { issue ->
|
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
|
||||||
IssueRow(issue) { onIssueSelected(issue) }
|
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 -> {
|
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
|
||||||
BackHandler(onBack = onBack)
|
}
|
||||||
ModeTabs(WorkItemsTab.PULL_REQUESTS, onShowIssues, onShowPullRequests)
|
is WorkItemsUiState.PullRequestList -> {
|
||||||
LazyColumn(Modifier.weight(1f)) {
|
BackHandler(onBack = onBack)
|
||||||
items(state.items, key = PullRequestSummary::number) { pr ->
|
ListHeader(state.target, onBack)
|
||||||
PullRequestRow(pr) { onPullRequestSelected(pr) }
|
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
|
@Composable
|
||||||
private fun ModeTabs(tab: WorkItemsTab, onShowIssues: () -> Unit, onShowPullRequests: () -> Unit) {
|
private fun ModeTabs(tab: WorkItemsTab, onShowIssues: () -> Unit, onShowPullRequests: () -> Unit) {
|
||||||
TabRow(selectedTabIndex = if (tab == WorkItemsTab.PULL_REQUESTS) 1 else 0) {
|
TabRow(selectedTabIndex = if (tab == WorkItemsTab.PULL_REQUESTS) 1 else 0) {
|
||||||
@ -155,119 +215,243 @@ private fun ModeTabs(tab: WorkItemsTab, onShowIssues: () -> Unit, onShowPullRequ
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun IssueRow(issue: IssueSummary, onSelected: () -> Unit) {
|
private fun WorkItemRow(
|
||||||
Column(Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 12.dp)) {
|
icon: Int,
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
tone: ActivityTone,
|
||||||
StatusBadge(issue.state)
|
number: Long,
|
||||||
Text("#${issue.number}", style = MaterialTheme.typography.bodySmall)
|
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
|
@Composable
|
||||||
private fun IssueDetailView(state: WorkItemsUiState.IssueDetailContent) {
|
private fun IssueDetailView(state: WorkItemsUiState.IssueDetailContent) {
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||||
item {
|
item(key = "header") {
|
||||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||||
StatusBadge(state.detail.state, Modifier.padding(bottom = 4.dp))
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Text(state.detail.title, style = MaterialTheme.typography.titleLarge)
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(stringResource(R.string.workitems_author, state.detail.author.label()), style = MaterialTheme.typography.bodySmall)
|
StatusBadge(stringResource(state.detail.state.labelResource), state.detail.state.tone())
|
||||||
if (state.detail.labels.isNotEmpty()) {
|
Text(
|
||||||
Text(state.detail.labels.joinToString(", "), style = MaterialTheme.typography.bodySmall)
|
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() }
|
item(key = "comments-title") { SectionTitle(stringResource(R.string.workitems_comments_title), Modifier.padding(top = 8.dp)) }
|
||||||
items(state.comments, key = CommentSummary::id) { CommentRow(it) }
|
if (state.comments.isEmpty()) {
|
||||||
if (state.comments.isEmpty()) item { CenterMessage { Text(stringResource(R.string.workitems_no_comments)) } }
|
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
|
@Composable
|
||||||
private fun PullRequestDetailView(state: WorkItemsUiState.PullRequestDetailContent) {
|
private fun PullRequestDetailView(state: WorkItemsUiState.PullRequestDetailContent) {
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
|
||||||
item {
|
item(key = "header") {
|
||||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||||
StatusBadge(state.detail.state, Modifier.padding(bottom = 4.dp))
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Text(state.detail.title, style = MaterialTheme.typography.titleLarge)
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(stringResource(R.string.workitems_author, state.detail.author.label()), style = MaterialTheme.typography.bodySmall)
|
StatusBadge(stringResource(state.detail.state.labelResource), state.detail.state.tone())
|
||||||
Text(stringResource(R.string.workitems_pr_refs, state.detail.baseRef, state.detail.headRef), style = MaterialTheme.typography.bodySmall)
|
Text(
|
||||||
Text(
|
stringResource(R.string.workitems_number, state.detail.number),
|
||||||
stringResource(if (state.detail.isMergeable) R.string.workitems_mergeable else R.string.workitems_not_mergeable),
|
style = MaterialTheme.typography.bodySmall,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
fontFamily = FontFamily.Monospace,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
Text(state.detail.body, modifier = Modifier.padding(top = 12.dp))
|
)
|
||||||
|
}
|
||||||
|
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 {
|
item(key = "reviews-title") { SectionTitle(stringResource(R.string.workitems_reviews_title), Modifier.padding(top = 8.dp)) }
|
||||||
Text(
|
if (state.reviews.isEmpty()) {
|
||||||
stringResource(R.string.workitems_reviews_title),
|
item(key = "reviews-empty") { CenteredMessage(stringResource(R.string.workitems_no_reviews), Modifier.padding(vertical = 8.dp)) }
|
||||||
style = MaterialTheme.typography.titleMedium,
|
} else {
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
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
|
@Composable
|
||||||
private fun ReviewRow(review: ReviewSummary) {
|
private fun ReviewCard(review: ReviewSummary) {
|
||||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
ListCard(Modifier.padding(horizontal = 20.dp, vertical = 6.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(review.author.label(), style = MaterialTheme.typography.bodySmall)
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(stringResource(review.state.labelResource), style = MaterialTheme.typography.bodySmall)
|
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
|
@Composable
|
||||||
private fun CommentRow(comment: CommentSummary) {
|
private fun CommentCard(comment: CommentSummary) {
|
||||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
ListCard(Modifier.padding(horizontal = 20.dp, vertical = 6.dp)) {
|
||||||
Text(comment.author.label(), style = MaterialTheme.typography.bodySmall)
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(comment.body, style = MaterialTheme.typography.bodyMedium)
|
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
|
@Composable
|
||||||
private fun ErrorMessage(type: WorkItemsError, onRetry: () -> Unit, onAuthenticationExpired: () -> Unit) {
|
private fun ErrorMessage(type: WorkItemsError, onRetry: () -> Unit, onAuthenticationExpired: () -> Unit) {
|
||||||
CenterMessage {
|
val expired = type == WorkItemsError.AUTHENTICATION_EXPIRED
|
||||||
Text(stringResource(type.messageResource))
|
CenteredMessage(
|
||||||
if (type == WorkItemsError.AUTHENTICATION_EXPIRED) {
|
stringResource(type.messageResource),
|
||||||
Button(onClick = onAuthenticationExpired) { Text(stringResource(R.string.workitems_sign_in_again)) }
|
action = stringResource(if (expired) R.string.workitems_sign_in_again else R.string.workitems_retry),
|
||||||
} else {
|
onAction = if (expired) onAuthenticationExpired else onRetry,
|
||||||
Button(onClick = onRetry) { Text(stringResource(R.string.workitems_retry)) }
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issues/PRs are the one list in this app where the server actually reports a total match count
|
// 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)
|
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
|
@Composable
|
||||||
private fun WorkItemAuthor.label(): String = when (this) {
|
private fun WorkItemAuthor.label(): String = when (this) {
|
||||||
is WorkItemAuthor.Known -> displayName?.takeIf(String::isNotBlank) ?: login
|
is WorkItemAuthor.Known -> displayName?.takeIf(String::isNotBlank) ?: login
|
||||||
@ -412,6 +586,11 @@ private val IssueState.labelResource: Int
|
|||||||
IssueState.CLOSED -> R.string.workitems_issue_closed
|
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
|
private val PullRequestState.labelResource: Int
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
PullRequestState.OPEN -> R.string.workitems_pr_open
|
PullRequestState.OPEN -> R.string.workitems_pr_open
|
||||||
@ -419,48 +598,10 @@ private val PullRequestState.labelResource: Int
|
|||||||
PullRequestState.MERGED -> R.string.workitems_pr_merged
|
PullRequestState.MERGED -> R.string.workitems_pr_merged
|
||||||
}
|
}
|
||||||
|
|
||||||
// 열림/닫힘/병합됨 used to render as a plain bodySmall line indistinguishable in weight from the
|
private fun PullRequestState.tone(): ActivityTone = when (this) {
|
||||||
// "#12" issue number next to it -- easy to miss when scanning a list. This reuses the same compact
|
PullRequestState.OPEN -> ActivityTone.SUCCESS
|
||||||
// colored-Surface badge treatment RepositoryListScreen's VisibilityBadge already established for
|
PullRequestState.CLOSED -> ActivityTone.DANGER
|
||||||
// 공개/비공개, borrowing existing theme roles rather than inventing new colors: OPEN gets the app's
|
PullRequestState.MERGED -> ActivityTone.MERGED
|
||||||
// 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 val ReviewState.labelResource: Int
|
private val ReviewState.labelResource: Int
|
||||||
@ -473,6 +614,15 @@ private val ReviewState.labelResource: Int
|
|||||||
ReviewState.OTHER -> R.string.workitems_review_other
|
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
|
private val WorkItemsError.messageResource: Int
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
WorkItemsError.OFFLINE -> R.string.workitems_offline
|
WorkItemsError.OFFLINE -> R.string.workitems_offline
|
||||||
|
|||||||
@ -133,6 +133,11 @@ class WorkItemsViewModel(private val repository: WorkItemsRepository) : ViewMode
|
|||||||
loadPullRequestDetail(target, pullRequest.number)
|
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() {
|
fun retryDetail() {
|
||||||
val current = mutableState.value as? WorkItemsUiState.DetailError ?: return
|
val current = mutableState.value as? WorkItemsUiState.DetailError ?: return
|
||||||
when (current.tab) {
|
when (current.tab) {
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
package com.ubisam.ubgitea.ui
|
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.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@ -9,6 +13,48 @@ import java.util.Locale
|
|||||||
private val instantFormatter: DateTimeFormatter =
|
private val instantFormatter: DateTimeFormatter =
|
||||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm (zzz)", Locale.KOREAN)
|
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. */
|
/** Formats an epoch-millis instant in the device's local time zone, with the zone shown explicitly. */
|
||||||
fun formatInstant(epochMillis: Long): String =
|
fun formatInstant(epochMillis: Long): String =
|
||||||
ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()).format(instantFormatter)
|
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
|
package com.ubisam.ubgitea.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.semantics.heading
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.ubisam.ubgitea.ui.theme.UbiSamLogoBlue
|
import com.ubisam.ubgitea.R
|
||||||
import com.ubisam.ubgitea.ui.theme.UbiSamLogoGray
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide header: the UbiSam wordmark, small, top-left, on every top-level screen. Nested screens
|
* The header of a top-level tab: the UbiSam wordmark, which Gitea instance and account are active
|
||||||
* (settings, notifications, branch detail, ...) keep the app's existing system-back-gesture
|
* (product.md: never obscure either), and the profile menu that holds settings and sign-out.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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()) {
|
Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxWidth()) {
|
||||||
Row(
|
Column(Modifier.padding(start = 20.dp, end = 8.dp, top = 8.dp, bottom = 8.dp)) {
|
||||||
modifier = Modifier
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
.fillMaxWidth()
|
Image(
|
||||||
.statusBarsPadding()
|
painter = painterResource(R.drawable.logo_ubisam),
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
contentDescription = stringResource(R.string.shell_logo_description),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
modifier = Modifier.height(20.dp),
|
||||||
) {
|
)
|
||||||
UBGiteaLogo()
|
Spacer(Modifier.weight(1f))
|
||||||
Spacer(Modifier.weight(1f))
|
actions()
|
||||||
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
|
@Composable
|
||||||
fun UBGiteaLogo(modifier: Modifier = Modifier) {
|
private fun ProfileMenu(accountName: String?, onViewSettings: () -> Unit, onLogout: () -> Unit) {
|
||||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
var expanded by remember { mutableStateOf(false) }
|
||||||
Text(
|
val description = stringResource(R.string.shell_profile_menu)
|
||||||
text = "Ubi",
|
Box {
|
||||||
style = MaterialTheme.typography.titleMedium,
|
IconButton(onClick = { expanded = true }) {
|
||||||
fontWeight = FontWeight.SemiBold,
|
Avatar(accountName.orEmpty(), size = 34.dp, contentDescription = description)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
}
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.repositories_settings)) },
|
||||||
// Stand-in for the real logo's signal/RSS mark, drawn rather than imported so the redesign needs
|
leadingIcon = { UBIcon(R.drawable.ic_ub_settings, null) },
|
||||||
// no new image asset: a dot plus two quarter-arcs, all centered on the bottom-left corner of the
|
onClick = { expanded = false; onViewSettings() },
|
||||||
// box, matching the mark in the real UbiSam wordmark. Swap for an actual drawable resource if/when
|
)
|
||||||
// the source image file is available.
|
DropdownMenuItem(
|
||||||
@Composable
|
text = { Text(stringResource(R.string.repositories_logout)) },
|
||||||
private fun SignalMark(color: Color, modifier: Modifier = Modifier) {
|
leadingIcon = { UBIcon(R.drawable.ic_ub_logout, null) },
|
||||||
Canvas(modifier = modifier) {
|
onClick = { expanded = false; onLogout() },
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* after process death, consistent with the app's no-offline-cache posture.
|
||||||
*/
|
*/
|
||||||
sealed interface Route {
|
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 Repositories : Route
|
||||||
|
data object Work : Route
|
||||||
|
data object Notifications : Route
|
||||||
|
|
||||||
// A single screen hosting two swipeable tabs — Files (the code browser) and History — over
|
// 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
|
// 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
|
// 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.
|
// 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 BranchDetail(val owner: String, val name: String, val refKind: String, val ref: String) : Route
|
||||||
data class WorkItems(val owner: String, val name: String) : Route
|
// number is set when the screen opens on one issue or pull request rather than on the list.
|
||||||
data object Notifications : Route
|
data class WorkItems(
|
||||||
|
val owner: String,
|
||||||
|
val name: String,
|
||||||
|
val number: Long? = null,
|
||||||
|
val isPullRequest: Boolean = false,
|
||||||
|
) : Route
|
||||||
data object Settings : 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) } },
|
save = { stack: List<Route> -> stack.map { route -> RouteEncoding.encode(route) } },
|
||||||
restore = { encoded: List<String> ->
|
restore = { encoded: List<String> ->
|
||||||
encoded.mapNotNull(RouteEncoding::decodeOrNull)
|
encoded.mapNotNull(RouteEncoding::decodeOrNull)
|
||||||
.ifEmpty { listOf(Route.Repositories) }
|
.ifEmpty { listOf(Route.Home) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
internal object RouteEncoding {
|
internal object RouteEncoding {
|
||||||
private const val FIELD_SEPARATOR = ''
|
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_REPOSITORIES = "repositories"
|
||||||
private const val TYPE_BRANCH_DETAIL = "branchDetail"
|
private const val TYPE_BRANCH_DETAIL = "branchDetail"
|
||||||
private const val TYPE_WORK_ITEMS = "workItems"
|
private const val TYPE_WORK_ITEMS = "workItems"
|
||||||
@ -28,14 +32,26 @@ internal object RouteEncoding {
|
|||||||
private const val TYPE_SETTINGS = "settings"
|
private const val TYPE_SETTINGS = "settings"
|
||||||
|
|
||||||
fun encode(route: Route): String = when (route) {
|
fun encode(route: Route): String = when (route) {
|
||||||
|
Route.Home -> TYPE_HOME
|
||||||
Route.Repositories -> TYPE_REPOSITORIES
|
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.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.Notifications -> TYPE_NOTIFICATIONS
|
||||||
Route.Settings -> TYPE_SETTINGS
|
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? {
|
fun decodeOrNull(encoded: String): Route? {
|
||||||
val parts = encoded.split(FIELD_SEPARATOR)
|
val parts = encoded.split(FIELD_SEPARATOR)
|
||||||
@ -43,9 +59,19 @@ internal object RouteEncoding {
|
|||||||
TYPE_BRANCH_DETAIL -> parts.takeIf { it.size == 5 }?.let {
|
TYPE_BRANCH_DETAIL -> parts.takeIf { it.size == 5 }?.let {
|
||||||
Route.BranchDetail(owner = it[1], name = it[2], refKind = it[3], ref = it[4])
|
Route.BranchDetail(owner = it[1], name = it[2], refKind = it[3], ref = it[4])
|
||||||
}
|
}
|
||||||
TYPE_WORK_ITEMS -> parts.takeIf { it.size == 3 }?.let {
|
TYPE_WORK_ITEMS -> when (parts.size) {
|
||||||
Route.WorkItems(owner = it[1], name = it[2])
|
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_NOTIFICATIONS -> Route.Notifications
|
||||||
TYPE_SETTINGS -> Route.Settings
|
TYPE_SETTINGS -> Route.Settings
|
||||||
TYPE_REPOSITORIES -> Route.Repositories
|
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
|
package com.ubisam.ubgitea.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
// Sky-blue + white redesign (superseding the earlier "Gitea green" theme -- see git history for
|
// Bright sky blue + white (TASK-050, replacing TASK-045's calmer sky-700 palette). The brand color
|
||||||
// that palette if it's ever needed again). Primary is a deep-enough sky blue (Tailwind's sky-700
|
// is the blue of the UbiSam wordmark itself (BrandSky). White text on that blue only reaches about
|
||||||
// family) that white text on it clears WCAG's 4.5:1 threshold for normal-size button labels, while
|
// 3:1, so it is used for marks that carry no text -- icons, activity bars, selection indicators --
|
||||||
// still reading unmistakably as "sky blue" rather than navy. Background is a near-white with a
|
// while anything with a label on it (buttons, links) uses the deeper SkyPrimaryLight, which clears
|
||||||
// faint blue cast rather than stark #FFFFFF, so white cards/surfaces still have visible separation
|
// WCAG's 4.5:1. Status colors (open / merged / closed) deliberately stay outside the blue family:
|
||||||
// from the page. Every other role here is a hand-built, standard-shaped Material3 tonal palette
|
// they follow Git hosting convention, and a viewer has to tell them apart from the brand color and
|
||||||
// (primary/secondary/tertiary, each with a container + on-* pair, plus neutrals) grown from that
|
// from each other at a glance.
|
||||||
// same blue family, so the whole app reads as one deliberate sky-blue theme.
|
|
||||||
|
val BrandSky = Color(0xFF2F98F9)
|
||||||
|
|
||||||
// Light theme
|
// Light theme
|
||||||
val SkyPrimaryLight = Color(0xFF0369A1)
|
val SkyPrimaryLight = Color(0xFF0A6FD8)
|
||||||
val OnSkyPrimaryLight = Color(0xFFFFFFFF)
|
val OnSkyPrimaryLight = Color(0xFFFFFFFF)
|
||||||
val SkyPrimaryContainerLight = Color(0xFFBAE6FD)
|
val SkyPrimaryContainerLight = Color(0xFFDCEEFF)
|
||||||
val OnSkyPrimaryContainerLight = Color(0xFF082F49)
|
val OnSkyPrimaryContainerLight = Color(0xFF07509E)
|
||||||
val SlateSecondaryLight = Color(0xFF4C6B82)
|
val SlateSecondaryLight = Color(0xFF4B5D73)
|
||||||
val OnSlateSecondaryLight = Color(0xFFFFFFFF)
|
val OnSlateSecondaryLight = Color(0xFFFFFFFF)
|
||||||
val SlateSecondaryContainerLight = Color(0xFFD3E4F0)
|
val SlateSecondaryContainerLight = Color(0xFFEAF4FF)
|
||||||
val OnSlateSecondaryContainerLight = Color(0xFF0F2A3A)
|
val OnSlateSecondaryContainerLight = Color(0xFF0E1B2C)
|
||||||
val CyanTertiaryLight = Color(0xFF006874)
|
val PurpleTertiaryLight = Color(0xFF7A3FD0)
|
||||||
val OnCyanTertiaryLight = Color(0xFFFFFFFF)
|
val OnPurpleTertiaryLight = Color(0xFFFFFFFF)
|
||||||
val CyanTertiaryContainerLight = Color(0xFFA6EEF2)
|
val PurpleTertiaryContainerLight = Color(0xFFEEE5FC)
|
||||||
val OnCyanTertiaryContainerLight = Color(0xFF001F24)
|
val OnPurpleTertiaryContainerLight = Color(0xFF4A1F8F)
|
||||||
val BackgroundLight = Color(0xFFF7FBFF)
|
val ErrorLight = Color(0xFFC62535)
|
||||||
val OnBackgroundLight = Color(0xFF10202B)
|
val OnErrorLight = Color(0xFFFFFFFF)
|
||||||
val SurfaceVariantLight = Color(0xFFDCE8EF)
|
val ErrorContainerLight = Color(0xFFFDE4E6)
|
||||||
val OnSurfaceVariantLight = Color(0xFF41505A)
|
val OnErrorContainerLight = Color(0xFF8E1320)
|
||||||
val OutlineLight = Color(0xFF71818C)
|
val BackgroundLight = Color(0xFFF3F8FE)
|
||||||
val OutlineVariantLight = Color(0xFFC0D0D9)
|
val SurfaceLight = Color(0xFFFFFFFF)
|
||||||
val InverseSurfaceLight = Color(0xFF1D333D)
|
val OnBackgroundLight = Color(0xFF0E1B2C)
|
||||||
val InverseOnSurfaceLight = Color(0xFFECF3F8)
|
val SurfaceVariantLight = Color(0xFFEAF4FF)
|
||||||
val InversePrimaryLight = Color(0xFF8ED0FA)
|
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
|
// Dark theme
|
||||||
// surface; the light theme's primary becomes the dark theme's inversePrimary, and vice versa).
|
val SkyPrimaryDark = Color(0xFF6BB8FF)
|
||||||
val SkyPrimaryDark = Color(0xFF8ED0FA)
|
val OnSkyPrimaryDark = Color(0xFF04213F)
|
||||||
val OnSkyPrimaryDark = Color(0xFF00344D)
|
val SkyPrimaryContainerDark = Color(0xFF123A63)
|
||||||
val SkyPrimaryContainerDark = Color(0xFF024C6E)
|
val OnSkyPrimaryContainerDark = Color(0xFFBFE0FF)
|
||||||
val OnSkyPrimaryContainerDark = Color(0xFFBAE6FD)
|
val SlateSecondaryDark = Color(0xFF9FB2C8)
|
||||||
val SlateSecondaryDark = Color(0xFFB4CADD)
|
val OnSlateSecondaryDark = Color(0xFF0B1B2D)
|
||||||
val OnSlateSecondaryDark = Color(0xFF1C3444)
|
val SlateSecondaryContainerDark = Color(0xFF16293F)
|
||||||
val SlateSecondaryContainerDark = Color(0xFF34495C)
|
val OnSlateSecondaryContainerDark = Color(0xFFE8F0FA)
|
||||||
val OnSlateSecondaryContainerDark = Color(0xFFD3E4F0)
|
val PurpleTertiaryDark = Color(0xFFBC95F7)
|
||||||
val CyanTertiaryDark = Color(0xFF83D2DB)
|
val OnPurpleTertiaryDark = Color(0xFF2A1B4A)
|
||||||
val OnCyanTertiaryDark = Color(0xFF00363D)
|
val PurpleTertiaryContainerDark = Color(0xFF2A1B4A)
|
||||||
val CyanTertiaryContainerDark = Color(0xFF004F58)
|
val OnPurpleTertiaryContainerDark = Color(0xFFE2D3FF)
|
||||||
val OnCyanTertiaryContainerDark = Color(0xFFA6EEF2)
|
val ErrorDark = Color(0xFFFF8A84)
|
||||||
val BackgroundDark = Color(0xFF0B141B)
|
val OnErrorDark = Color(0xFF3B0A0D)
|
||||||
val OnBackgroundDark = Color(0xFFE3EDF3)
|
val ErrorContainerDark = Color(0xFF471A1D)
|
||||||
val SurfaceVariantDark = Color(0xFF41505A)
|
val OnErrorContainerDark = Color(0xFFFFD0CD)
|
||||||
val OnSurfaceVariantDark = Color(0xFFC0D0D9)
|
val BackgroundDark = Color(0xFF08121F)
|
||||||
val OutlineDark = Color(0xFF8B9AA4)
|
val SurfaceDark = Color(0xFF101D2E)
|
||||||
val OutlineVariantDark = Color(0xFF41505A)
|
val OnBackgroundDark = Color(0xFFE8F0FA)
|
||||||
val InverseSurfaceDark = Color(0xFFE3EDF3)
|
val SurfaceVariantDark = Color(0xFF16293F)
|
||||||
val InverseOnSurfaceDark = Color(0xFF1D333D)
|
val OnSurfaceVariantDark = Color(0xFF9FB2C8)
|
||||||
val InversePrimaryDark = Color(0xFF0369A1)
|
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).
|
* Roles Material3's own scheme has no slot for. `success` is the "open" / "added" color, `warning`
|
||||||
val UbiSamLogoGray = Color(0xFF5B6472)
|
* the caution color (offline data, truncation); "merged" is the scheme's tertiary (purple) and
|
||||||
val UbiSamLogoBlue = Color(0xFF0EA5E9)
|
* "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.darkColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
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
|
// See Color.kt for the individual values and why each role has the color it has. Surfaces are
|
||||||
// derived). Every role not set explicitly here -- error/onError/errorContainer/onErrorContainer,
|
// white cards on a faintly blue page in light mode, so `surface` and `background` differ.
|
||||||
// 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.
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = SkyPrimaryLight,
|
primary = SkyPrimaryLight,
|
||||||
onPrimary = OnSkyPrimaryLight,
|
onPrimary = OnSkyPrimaryLight,
|
||||||
@ -21,18 +19,27 @@ private val LightColorScheme = lightColorScheme(
|
|||||||
onSecondary = OnSlateSecondaryLight,
|
onSecondary = OnSlateSecondaryLight,
|
||||||
secondaryContainer = SlateSecondaryContainerLight,
|
secondaryContainer = SlateSecondaryContainerLight,
|
||||||
onSecondaryContainer = OnSlateSecondaryContainerLight,
|
onSecondaryContainer = OnSlateSecondaryContainerLight,
|
||||||
tertiary = CyanTertiaryLight,
|
tertiary = PurpleTertiaryLight,
|
||||||
onTertiary = OnCyanTertiaryLight,
|
onTertiary = OnPurpleTertiaryLight,
|
||||||
tertiaryContainer = CyanTertiaryContainerLight,
|
tertiaryContainer = PurpleTertiaryContainerLight,
|
||||||
onTertiaryContainer = OnCyanTertiaryContainerLight,
|
onTertiaryContainer = OnPurpleTertiaryContainerLight,
|
||||||
|
error = ErrorLight,
|
||||||
|
onError = OnErrorLight,
|
||||||
|
errorContainer = ErrorContainerLight,
|
||||||
|
onErrorContainer = OnErrorContainerLight,
|
||||||
background = BackgroundLight,
|
background = BackgroundLight,
|
||||||
onBackground = OnBackgroundLight,
|
onBackground = OnBackgroundLight,
|
||||||
surface = BackgroundLight,
|
surface = SurfaceLight,
|
||||||
onSurface = OnBackgroundLight,
|
onSurface = OnBackgroundLight,
|
||||||
surfaceVariant = SurfaceVariantLight,
|
surfaceVariant = SurfaceVariantLight,
|
||||||
onSurfaceVariant = OnSurfaceVariantLight,
|
onSurfaceVariant = OnSurfaceVariantLight,
|
||||||
outline = OutlineLight,
|
outline = OutlineLight,
|
||||||
outlineVariant = OutlineVariantLight,
|
outlineVariant = OutlineVariantLight,
|
||||||
|
surfaceContainerLowest = SurfaceContainerLowestLight,
|
||||||
|
surfaceContainerLow = SurfaceContainerLowLight,
|
||||||
|
surfaceContainer = SurfaceContainerLight,
|
||||||
|
surfaceContainerHigh = SurfaceContainerHighLight,
|
||||||
|
surfaceContainerHighest = SurfaceContainerHighestLight,
|
||||||
inverseSurface = InverseSurfaceLight,
|
inverseSurface = InverseSurfaceLight,
|
||||||
inverseOnSurface = InverseOnSurfaceLight,
|
inverseOnSurface = InverseOnSurfaceLight,
|
||||||
inversePrimary = InversePrimaryLight,
|
inversePrimary = InversePrimaryLight,
|
||||||
@ -47,18 +54,27 @@ private val DarkColorScheme = darkColorScheme(
|
|||||||
onSecondary = OnSlateSecondaryDark,
|
onSecondary = OnSlateSecondaryDark,
|
||||||
secondaryContainer = SlateSecondaryContainerDark,
|
secondaryContainer = SlateSecondaryContainerDark,
|
||||||
onSecondaryContainer = OnSlateSecondaryContainerDark,
|
onSecondaryContainer = OnSlateSecondaryContainerDark,
|
||||||
tertiary = CyanTertiaryDark,
|
tertiary = PurpleTertiaryDark,
|
||||||
onTertiary = OnCyanTertiaryDark,
|
onTertiary = OnPurpleTertiaryDark,
|
||||||
tertiaryContainer = CyanTertiaryContainerDark,
|
tertiaryContainer = PurpleTertiaryContainerDark,
|
||||||
onTertiaryContainer = OnCyanTertiaryContainerDark,
|
onTertiaryContainer = OnPurpleTertiaryContainerDark,
|
||||||
|
error = ErrorDark,
|
||||||
|
onError = OnErrorDark,
|
||||||
|
errorContainer = ErrorContainerDark,
|
||||||
|
onErrorContainer = OnErrorContainerDark,
|
||||||
background = BackgroundDark,
|
background = BackgroundDark,
|
||||||
onBackground = OnBackgroundDark,
|
onBackground = OnBackgroundDark,
|
||||||
surface = BackgroundDark,
|
surface = SurfaceDark,
|
||||||
onSurface = OnBackgroundDark,
|
onSurface = OnBackgroundDark,
|
||||||
surfaceVariant = SurfaceVariantDark,
|
surfaceVariant = SurfaceVariantDark,
|
||||||
onSurfaceVariant = OnSurfaceVariantDark,
|
onSurfaceVariant = OnSurfaceVariantDark,
|
||||||
outline = OutlineDark,
|
outline = OutlineDark,
|
||||||
outlineVariant = OutlineVariantDark,
|
outlineVariant = OutlineVariantDark,
|
||||||
|
surfaceContainerLowest = SurfaceContainerLowestDark,
|
||||||
|
surfaceContainerLow = SurfaceContainerLowDark,
|
||||||
|
surfaceContainer = SurfaceContainerDark,
|
||||||
|
surfaceContainerHigh = SurfaceContainerHighDark,
|
||||||
|
surfaceContainerHighest = SurfaceContainerHighestDark,
|
||||||
inverseSurface = InverseSurfaceDark,
|
inverseSurface = InverseSurfaceDark,
|
||||||
inverseOnSurface = InverseOnSurfaceDark,
|
inverseOnSurface = InverseOnSurfaceDark,
|
||||||
inversePrimary = InversePrimaryDark,
|
inversePrimary = InversePrimaryDark,
|
||||||
@ -69,8 +85,15 @@ fun UBGiteaTheme(
|
|||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
MaterialTheme(
|
CompositionLocalProvider(LocalUBGiteaColors provides if (darkTheme) DarkExtendedColors else LightExtendedColors) {
|
||||||
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
|
MaterialTheme(
|
||||||
content = content,
|
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>
|
<resources>
|
||||||
<string name="app_name">UBGitea</string>
|
<string name="app_name">UBGitea</string>
|
||||||
<string name="home_read_only_placeholder">저장소, 브랜치와 코드를 안전하게 둘러볼 읽기 전용 앱을 준비하고 있습니다.</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="branchdetail_tab_files">파일</string>
|
<string name="branchdetail_tab_files">파일</string>
|
||||||
<string name="branchdetail_tab_history">커밋</string>
|
<string name="branchdetail_tab_history">커밋</string>
|
||||||
|
<string name="branchdetail_subtitle">%1$s · %2$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -31,4 +31,45 @@
|
|||||||
<string name="codebrowser_image_decode_failed">이미지를 표시할 수 없습니다.</string>
|
<string name="codebrowser_image_decode_failed">이미지를 표시할 수 없습니다.</string>
|
||||||
<string name="codebrowser_csv_empty">이 CSV 파일은 비어 있습니다.</string>
|
<string name="codebrowser_csv_empty">이 CSV 파일은 비어 있습니다.</string>
|
||||||
<string name="codebrowser_pdf_decode_failed">PDF를 표시할 수 없습니다.</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>
|
</resources>
|
||||||
|
|||||||
@ -27,4 +27,21 @@
|
|||||||
<string name="history_conflict">저장소가 비어 있거나 비교할 수 없는 상태입니다.</string>
|
<string name="history_conflict">저장소가 비어 있거나 비교할 수 없는 상태입니다.</string>
|
||||||
<string name="history_unprocessable">요청을 처리할 수 없습니다.</string>
|
<string name="history_unprocessable">요청을 처리할 수 없습니다.</string>
|
||||||
<string name="history_server_error">Gitea 서버에서 오류가 발생했습니다.</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>
|
</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_title">알림</string>
|
||||||
<string name="notifications_filter_unread">읽지 않음</string>
|
<string name="notifications_filter_unread">읽지 않음</string>
|
||||||
<string name="notifications_filter_all">전체</string>
|
<string name="notifications_filter_all">전체</string>
|
||||||
<string name="notifications_unread_label">[읽지 않음]</string>
|
<string name="notifications_unread_dot_description">읽지 않음</string>
|
||||||
<string name="notifications_empty">알림이 없습니다.</string>
|
<string name="notifications_empty">알림이 없습니다.</string>
|
||||||
<string name="notifications_retry">다시 시도</string>
|
<string name="notifications_retry">다시 시도</string>
|
||||||
<string name="notifications_sign_in_again">다시 로그인</string>
|
<string name="notifications_sign_in_again">다시 로그인</string>
|
||||||
<string name="notifications_previous_page">이전 페이지</string>
|
<string name="notifications_previous_page">이전 페이지</string>
|
||||||
<string name="notifications_next_page">다음 페이지</string>
|
<string name="notifications_next_page">다음 페이지</string>
|
||||||
<string name="notifications_page_number">%1$d페이지</string>
|
<string name="notifications_page_number">%1$d페이지</string>
|
||||||
<string name="notifications_repository">%1$s/%2$s</string>
|
<string name="notifications_reference">%1$s #%2$d</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_state_open">열림</string>
|
<string name="notifications_state_open">열림</string>
|
||||||
<string name="notifications_state_closed">닫힘</string>
|
<string name="notifications_state_closed">닫힘</string>
|
||||||
<string name="notifications_state_merged">병합됨</string>
|
<string name="notifications_state_merged">병합됨</string>
|
||||||
|
|||||||
@ -18,24 +18,17 @@
|
|||||||
<string name="repositories_logout">로그아웃</string>
|
<string name="repositories_logout">로그아웃</string>
|
||||||
<string name="repositories_private">비공개</string>
|
<string name="repositories_private">비공개</string>
|
||||||
<string name="repositories_public">공개</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_references_loading">로딩 중</string>
|
||||||
<string name="repositories_branches_title">브랜치</string>
|
<string name="repositories_branches_title">브랜치</string>
|
||||||
<string name="repositories_branches_empty">브랜치가 없습니다.</string>
|
<string name="repositories_branches_empty">브랜치가 없습니다.</string>
|
||||||
<string name="repositories_tags_title">태그</string>
|
<string name="repositories_tags_title">태그</string>
|
||||||
<string name="repositories_tags_empty">태그가 없습니다.</string>
|
<string name="repositories_tags_empty">태그가 없습니다.</string>
|
||||||
<string name="repositories_protected_branch">보호된 브랜치</string>
|
<string name="repositories_protected_branch">보호된 브랜치</string>
|
||||||
<string name="repositories_commit_at">커밋 시각: %1$s</string>
|
|
||||||
<string name="repositories_back">저장소 목록</string>
|
<string name="repositories_back">저장소 목록</string>
|
||||||
<string name="repositories_refresh">새로고침</string>
|
|
||||||
<string name="repositories_previous_page">이전 페이지</string>
|
<string name="repositories_previous_page">이전 페이지</string>
|
||||||
<string name="repositories_next_page">다음 페이지</string>
|
<string name="repositories_next_page">다음 페이지</string>
|
||||||
<string name="repositories_page_number">%1$d페이지</string>
|
<string name="repositories_page_number">%1$d페이지</string>
|
||||||
<string name="repositories_last_pushed_branch_loading">마지막 push 브랜치 확인 중…</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_none">브랜치가 없습니다.</string>
|
||||||
<string name="repositories_last_pushed_branch_failed">마지막 push 브랜치를 불러오지 못했습니다.</string>
|
<string name="repositories_last_pushed_branch_failed">마지막 push 브랜치를 불러오지 못했습니다.</string>
|
||||||
<string name="repositories_tab_all">전체 저장소</string>
|
<string name="repositories_tab_all">전체 저장소</string>
|
||||||
@ -48,4 +41,5 @@
|
|||||||
<string name="repositories_favorited">즐겨찾기됨</string>
|
<string name="repositories_favorited">즐겨찾기됨</string>
|
||||||
<string name="repositories_work_items">PR/이슈</string>
|
<string name="repositories_work_items">PR/이슈</string>
|
||||||
<string name="repositories_organization_all">전체</string>
|
<string name="repositories_organization_all">전체</string>
|
||||||
|
<string name="repositories_list_title">저장소</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -4,4 +4,12 @@
|
|||||||
<string name="settings_reactive_auth_retry_description">인증이 예기치 않게 만료되면, 재로그인 화면을 띄우기 전에 한 번 더 조용히 갱신을 시도합니다.</string>
|
<string name="settings_reactive_auth_retry_description">인증이 예기치 않게 만료되면, 재로그인 화면을 띄우기 전에 한 번 더 조용히 갱신을 시도합니다.</string>
|
||||||
<string name="settings_favorite_push_notifications_title">즐겨찾기 저장소 Push 알림</string>
|
<string name="settings_favorite_push_notifications_title">즐겨찾기 저장소 Push 알림</string>
|
||||||
<string name="settings_favorite_push_notifications_description">즐겨찾기한 저장소에 새로운 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>
|
</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_suffix">/ %1$d ]</string>
|
||||||
<string name="workitems_page_jump_go">이동</string>
|
<string name="workitems_page_jump_go">이동</string>
|
||||||
<string name="workitems_page_jump_out_of_range">1~%1$d 사이의 페이지를 입력하세요.</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_author_unavailable">(알 수 없는 사용자)</string>
|
||||||
<string name="workitems_assignees">담당자: %1$s</string>
|
<string name="workitems_assignees">담당자: %1$s</string>
|
||||||
<string name="workitems_no_comments">댓글이 없습니다.</string>
|
<string name="workitems_no_comments">댓글이 없습니다.</string>
|
||||||
<string name="workitems_no_reviews">리뷰가 없습니다.</string>
|
<string name="workitems_no_reviews">리뷰가 없습니다.</string>
|
||||||
<string name="workitems_reviews_title">리뷰</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_mergeable">병합 가능</string>
|
||||||
<string name="workitems_not_mergeable">병합 불가</string>
|
<string name="workitems_not_mergeable">병합 불가</string>
|
||||||
<string name="workitems_issue_open">열림</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
|
package com.ubisam.ubgitea.domain.history
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@ -132,4 +133,87 @@ class UnifiedDiffParserTest {
|
|||||||
assertEquals(DiffLine.Removed("bye"), rows[0].left)
|
assertEquals(DiffLine.Removed("bye"), rows[0].left)
|
||||||
assertNull(rows[0].right)
|
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
|
@Test
|
||||||
fun colorsHexAndSuffixedNumbers() {
|
fun colorsHexAndSuffixedNumbers() {
|
||||||
assertEquals(
|
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(),
|
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;")
|
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("int" to TokenRole.KEYWORD, "/* starts" to TokenRole.COMMENT), result[0])
|
||||||
assertEquals(
|
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],
|
result[1],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -173,4 +178,102 @@ class SyntaxHighlighterTest {
|
|||||||
assertEquals(listOf("int a;" to TokenRole.COMMENT), result[1])
|
assertEquals(listOf("int a;" to TokenRole.COMMENT), result[1])
|
||||||
assertEquals(listOf("int b;" to TokenRole.COMMENT), result[2])
|
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 androidx.compose.runtime.saveable.SaverScope
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class RouteEncodingTest {
|
class RouteEncodingTest {
|
||||||
@ -10,6 +11,45 @@ class RouteEncodingTest {
|
|||||||
assertEquals(Route.Repositories, RouteEncoding.decode(RouteEncoding.encode(Route.Repositories)))
|
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
|
@Test
|
||||||
fun `branch detail route round-trips including a ref containing a slash`() {
|
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")
|
val route = Route.BranchDetail(owner = "team name", name = "repo+#", refKind = "BRANCH", ref = "refs/heads/feature/a b")
|
||||||
@ -28,14 +68,14 @@ class RouteEncodingTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an unrecognized encoding falls back to Repositories instead of crashing`() {
|
fun `an unrecognized encoding falls back to Home instead of crashing`() {
|
||||||
assertEquals(Route.Repositories, RouteEncoding.decode("not-a-known-route-type"))
|
assertEquals(Route.Home, RouteEncoding.decode("not-a-known-route-type"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `truncated persisted routes fall back instead of indexing missing fields`() {
|
fun `truncated persisted routes fall back instead of indexing missing fields`() {
|
||||||
assertEquals(Route.Repositories, RouteEncoding.decode("branchDetail" + '' + "owner"))
|
assertEquals(Route.Home, RouteEncoding.decode("branchDetail" + '' + "owner"))
|
||||||
assertEquals(Route.Repositories, RouteEncoding.decode("workItems" + '' + "owner"))
|
assertEquals(Route.Home, RouteEncoding.decode("workItems" + '' + "owner"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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