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:
최민영 2026-09-28 14:35:00 +09:00
parent 105e181bd2
commit d1a45abc16
109 changed files with 9894 additions and 1646 deletions

View File

@ -7,39 +7,54 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectType
import com.ubisam.ubgitea.feature.branchdetail.BranchDetailScreen
import com.ubisam.ubgitea.feature.branchdetail.isReadingFullScreen
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel
import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind
import com.ubisam.ubgitea.feature.codebrowser.CodeViewerPreferences
import com.ubisam.ubgitea.feature.history.HistoryTarget
import com.ubisam.ubgitea.feature.history.HistoryViewModel
import com.ubisam.ubgitea.feature.home.HomeDestination
import com.ubisam.ubgitea.feature.home.HomeScreen
import com.ubisam.ubgitea.feature.home.HomeViewModel
import com.ubisam.ubgitea.feature.notifications.NotificationsScreen
import com.ubisam.ubgitea.feature.notifications.NotificationsViewModel
import com.ubisam.ubgitea.feature.repositories.ReferenceKind
import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen
import com.ubisam.ubgitea.feature.repositories.RepositoryListUiState
import com.ubisam.ubgitea.feature.repositories.accountNameOrNull
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
import com.ubisam.ubgitea.feature.settings.SettingsScreen
import com.ubisam.ubgitea.feature.settings.SettingsViewModel
import com.ubisam.ubgitea.feature.work.WorkScreen
import com.ubisam.ubgitea.feature.work.WorkViewModel
import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen
import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget
import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel
import com.ubisam.ubgitea.ui.navigation.Route
import com.ubisam.ubgitea.ui.navigation.RouteStackSaver
import com.ubisam.ubgitea.ui.shell.AppShell
import com.ubisam.ubgitea.ui.shell.AppTab
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
class MainActivity : ComponentActivity() {
@ -61,6 +76,12 @@ class MainActivity : ComponentActivity() {
private val settingsViewModel: SettingsViewModel by viewModels {
SettingsViewModel.Factory(application)
}
private val homeViewModel: HomeViewModel by viewModels {
HomeViewModel.Factory(application)
}
private val workViewModel: WorkViewModel by viewModels {
WorkViewModel.Factory(application)
}
// TASK-036: no-op if already granted or already permanently denied -- the OS itself decides
// whether to actually show a prompt, this call is safe to make on every launch. Must be
@ -87,15 +108,29 @@ class MainActivity : ComponentActivity() {
val historyState by historyViewModel.state.collectAsState()
val workItemsState by workItemsViewModel.state.collectAsState()
val notificationsState by notificationsViewModel.state.collectAsState()
val unreadNotifications by notificationsViewModel.unreadCount.collectAsState()
val settingsState by settingsViewModel.state.collectAsState()
val homeState by homeViewModel.state.collectAsState()
val workState by workViewModel.state.collectAsState()
var routeStack by rememberSaveable(stateSaver = RouteStackSaver) {
mutableStateOf(listOf<Route>(Route.Repositories))
mutableStateOf(listOf<Route>(Route.Home))
}
fun push(route: Route) { routeStack = routeStack + route }
fun popToRepositories() { routeStack = listOf(Route.Repositories) }
fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Repositories) } }
fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Home) } }
fun selectTab(tab: AppTab) { routeStack = listOf(tab.route) }
val currentRoute = routeStack.last()
val currentTab = routeStack.firstNotNullOfOrNull { it.tab } ?: AppTab.HOME
// The repository list's ViewModel owns sign-in, so its state says whether there is a
// session. While it is merely loading, whatever was true before still is.
var sessionReady by rememberSaveable { mutableStateOf(false) }
var accountName by rememberSaveable { mutableStateOf<String?>(null) }
LaunchedEffect(state) {
state.sessionReadyOrNull()?.let { sessionReady = it }
if (state is RepositoryListUiState.SignedOut) accountName = null
state.accountNameOrNull()?.let { accountName = it }
}
// TASK-036: tapping a favorite-push notification. Pushed on top of whatever's
// currently showing rather than resetting the stack -- consistent with how every other
@ -107,139 +142,258 @@ class MainActivity : ComponentActivity() {
}
}
LaunchedEffect(currentRoute) {
LaunchedEffect(currentRoute, sessionReady) {
if (!sessionReady) return@LaunchedEffect
if (currentRoute.isTab) notificationsViewModel.refreshUnreadCount()
when (val route = currentRoute) {
Route.Home -> homeViewModel.open()
Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation.
Route.Work -> workViewModel.open()
is Route.BranchDetail -> {
val repository = RepositoryCoordinates.of(route.owner, route.name)
val ref = RepositoryRef.of(route.ref)
codeBrowserViewModel.open(CodeBrowserTarget(repository, ref, CodeReferenceKind.valueOf(route.refKind)))
historyViewModel.open(HistoryTarget(repository, ref))
}
is Route.WorkItems -> workItemsViewModel.open(
WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name)),
)
is Route.WorkItems -> {
val target = WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name))
if (route.number != null) {
workItemsViewModel.openItem(target, route.number, route.isPullRequest)
} else {
workItemsViewModel.open(target)
}
}
Route.Notifications -> notificationsViewModel.open()
Route.Settings -> Unit // SettingsViewModel reads its (synchronous) store at construction.
}
}
fun authenticationExpired() {
fun closeFeatures() {
codeBrowserViewModel.close()
historyViewModel.close()
workItemsViewModel.close()
notificationsViewModel.close()
popToRepositories()
notificationsViewModel.clearUnreadCount()
homeViewModel.close()
workViewModel.close()
}
fun signOut() {
closeFeatures()
selectTab(AppTab.HOME)
viewModel.logout()
}
fun leaveBranchDetail() {
codeBrowserViewModel.close()
historyViewModel.close()
pop()
}
val instanceHost = UBGiteaOAuthDefaults.config.baseUri.host.orEmpty()
val codeViewerPreferences = CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
val showNavigation = sessionReady && when (currentRoute) {
is Route.BranchDetail -> !isReadingFullScreen(codeBrowserState, historyState)
Route.Settings -> false
else -> true
}
UBGiteaTheme {
when (currentRoute) {
Route.Settings -> SettingsScreen(
state = settingsState,
onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled,
onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled,
onBack = { pop() },
)
Route.Notifications -> NotificationsScreen(
state = notificationsState,
onShowUnreadOnly = notificationsViewModel::showUnreadOnly,
onShowAll = notificationsViewModel::showAll,
onNotificationSelected = { notification ->
push(Route.WorkItems(notification.repositoryOwner, notification.repositoryName))
},
onNextPage = notificationsViewModel::nextPage,
onPreviousPage = notificationsViewModel::previousPage,
onRetry = notificationsViewModel::retry,
onBack = {
notificationsViewModel.close()
pop()
},
onAuthenticationExpired = ::authenticationExpired,
)
is Route.WorkItems -> WorkItemsScreen(
state = workItemsState,
onShowIssues = workItemsViewModel::showIssues,
onShowPullRequests = workItemsViewModel::showPullRequests,
onIssueSelected = workItemsViewModel::openIssue,
onPullRequestSelected = workItemsViewModel::openPullRequest,
onNextPage = workItemsViewModel::nextPage,
onPreviousPage = workItemsViewModel::previousPage,
onGoToPage = workItemsViewModel::goToPage,
onRetryList = workItemsViewModel::retryList,
onRetryDetail = workItemsViewModel::retryDetail,
onBackToList = workItemsViewModel::backToList,
onBack = {
workItemsViewModel.close()
pop()
},
onAuthenticationExpired = ::authenticationExpired,
)
is Route.BranchDetail -> BranchDetailScreen(
codeBrowserState = codeBrowserState,
onEntrySelected = codeBrowserViewModel::openEntry,
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
onRetryCodeBrowser = codeBrowserViewModel::retry,
onCodeBrowserBack = {
if (!codeBrowserViewModel.navigateUp()) {
codeBrowserViewModel.close()
historyViewModel.close()
pop()
}
},
onNavigateImage = codeBrowserViewModel::navigateImage,
historyState = historyState,
onCommitSelected = historyViewModel::openCommit,
onHistoryNextPage = historyViewModel::nextPage,
onHistoryPreviousPage = historyViewModel::previousPage,
onRetryHistoryList = historyViewModel::retryList,
onRetryHistoryDetail = historyViewModel::retryDetail,
onRetryPatch = historyViewModel::retryPatch,
onFileSelected = historyViewModel::openFileDiff,
onClearFileSelection = historyViewModel::clearFileSelection,
onHistoryBackToList = historyViewModel::backToList,
onHistoryBack = {
AppShell(
selectedTab = currentTab,
showNavigation = showNavigation,
unreadNotifications = unreadNotifications,
onTabSelected = { tab ->
if (currentRoute is Route.BranchDetail) {
codeBrowserViewModel.close()
historyViewModel.close()
pop()
},
onAuthenticationExpired = ::authenticationExpired,
)
Route.Repositories -> RepositoryListScreen(
state = state,
onSignIn = viewModel::signIn,
onCancelSignIn = viewModel::cancelSignIn,
onRetry = viewModel::retry,
onLogout = viewModel::logout,
onRepositorySelected = viewModel::selectRepository,
onReferenceSelected = { repository, name, kind ->
viewModel.selectReference(name, kind)
val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG
push(
Route.BranchDetail(
owner = repository.coordinates.owner,
name = repository.coordinates.name,
refKind = refKind.name,
ref = name,
),
}
if (currentRoute is Route.WorkItems) workItemsViewModel.close()
selectTab(tab)
},
) { isWide ->
// Back on any tab but Home goes Home; back on Home leaves the app.
BackHandler(enabled = sessionReady && currentRoute.isTab && currentRoute != Route.Home) {
selectTab(AppTab.HOME)
}
val repositoryList: @Composable () -> Unit = {
RepositoryListScreen(
state = state,
instanceHost = instanceHost,
onSignIn = viewModel::signIn,
onCancelSignIn = viewModel::cancelSignIn,
onRetry = viewModel::retry,
onLogout = ::signOut,
onRepositorySelected = viewModel::selectRepository,
onReferenceSelected = { repository, name, kind ->
viewModel.selectReference(name, kind)
val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG
push(
Route.BranchDetail(
owner = repository.coordinates.owner,
name = repository.coordinates.name,
refKind = refKind.name,
ref = name,
),
)
},
onReferencesRefresh = viewModel::refreshReferences,
onBackToRepositories = viewModel::backToRepositories,
onNextPage = viewModel::nextPage,
onPreviousPage = viewModel::previousPage,
onRefreshRepositories = viewModel::refreshRepositories,
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
onToggleFavorite = viewModel::toggleFavorite,
onShowAllRepositories = viewModel::showAllRepositories,
onShowFavorites = viewModel::showFavorites,
onViewWorkItems = { repository ->
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
},
onSelectOrganization = viewModel::selectOrganization,
onViewSettings = { push(Route.Settings) },
)
}
when (val route = currentRoute.takeIf { sessionReady } ?: Route.Repositories) {
Route.Home -> HomeScreen(
state = homeState,
accountName = accountName,
instanceHost = instanceHost,
isWide = isWide,
onNavigate = { destination ->
when (destination) {
HomeDestination.Repositories -> selectTab(AppTab.REPOSITORIES)
is HomeDestination.Work -> {
workViewModel.open(destination.scope)
selectTab(AppTab.WORK)
}
is HomeDestination.Branch -> push(
Route.BranchDetail(
owner = destination.owner,
name = destination.name,
refKind = (if (destination.isTag) CodeReferenceKind.TAG else CodeReferenceKind.BRANCH).name,
ref = destination.ref,
),
)
is HomeDestination.WorkItem -> push(
Route.WorkItems(destination.owner, destination.name, destination.number, destination.isPullRequest),
)
}
},
onSelectOrganization = homeViewModel::selectOrganization,
onSelectFilter = homeViewModel::selectFilter,
onLoadMore = homeViewModel::loadMore,
onRefresh = homeViewModel::refresh,
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
onAuthenticationExpired = ::signOut,
)
Route.Work -> WorkScreen(
state = workState,
accountName = accountName,
instanceHost = instanceHost,
onSelectScope = workViewModel::selectScope,
onSelectState = workViewModel::selectState,
onSelectType = workViewModel::selectType,
onItemSelected = { hit ->
push(Route.WorkItems(hit.repository.owner, hit.repository.name, hit.number, hit.isPullRequest))
},
onNextPage = workViewModel::nextPage,
onPreviousPage = workViewModel::previousPage,
onRefresh = workViewModel::refresh,
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
onAuthenticationExpired = ::signOut,
)
Route.Settings -> SettingsScreen(
state = settingsState,
onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled,
onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled,
accountName = accountName,
instanceHost = instanceHost,
onSetCodeWordWrapEnabled = settingsViewModel::setCodeWordWrapEnabled,
onSetCodeFontSizeSp = settingsViewModel::setCodeFontSizeSp,
onLogout = ::signOut,
onBack = { pop() },
)
Route.Notifications -> NotificationsScreen(
state = notificationsState,
onShowUnreadOnly = notificationsViewModel::showUnreadOnly,
onShowAll = notificationsViewModel::showAll,
onNotificationSelected = { notification ->
push(
Route.WorkItems(
owner = notification.repositoryOwner,
name = notification.repositoryName,
number = notification.subjectNumber,
isPullRequest = notification.subjectType == NotificationSubjectType.PULL_REQUEST,
),
)
},
onNextPage = notificationsViewModel::nextPage,
onPreviousPage = notificationsViewModel::previousPage,
onRetry = notificationsViewModel::retry,
onBack = { selectTab(AppTab.HOME) },
onAuthenticationExpired = ::signOut,
accountName = accountName,
instanceHost = instanceHost,
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
)
is Route.WorkItems -> {
val leave = {
workItemsViewModel.close()
pop()
}
WorkItemsScreen(
state = workItemsState,
onShowIssues = workItemsViewModel::showIssues,
onShowPullRequests = workItemsViewModel::showPullRequests,
onIssueSelected = workItemsViewModel::openIssue,
onPullRequestSelected = workItemsViewModel::openPullRequest,
onNextPage = workItemsViewModel::nextPage,
onPreviousPage = workItemsViewModel::previousPage,
onGoToPage = workItemsViewModel::goToPage,
onRetryList = workItemsViewModel::retryList,
onRetryDetail = workItemsViewModel::retryDetail,
// An item opened directly came from a list elsewhere (a search, the
// timeline, a notification), so back returns there, not to this
// repository's own list.
onBackToList = if (route.number != null) leave else workItemsViewModel::backToList,
onBack = leave,
onAuthenticationExpired = ::signOut,
)
},
onReferencesRefresh = viewModel::refreshReferences,
onBackToRepositories = viewModel::backToRepositories,
onNextPage = viewModel::nextPage,
onPreviousPage = viewModel::previousPage,
onRefreshRepositories = viewModel::refreshRepositories,
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
onToggleFavorite = viewModel::toggleFavorite,
onShowAllRepositories = viewModel::showAllRepositories,
onShowFavorites = viewModel::showFavorites,
onViewWorkItems = { repository ->
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
},
onSelectOrganization = viewModel::selectOrganization,
onViewNotifications = { push(Route.Notifications) },
onViewSettings = { push(Route.Settings) },
)
}
is Route.BranchDetail -> BranchDetailScreen(
owner = route.owner,
name = route.name,
ref = route.ref,
codeBrowserState = codeBrowserState,
codeViewerPreferences = codeViewerPreferences,
onEntrySelected = codeBrowserViewModel::openEntry,
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
onRetryCodeBrowser = codeBrowserViewModel::retry,
onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) leaveBranchDetail() },
onNavigateImage = codeBrowserViewModel::navigateImage,
onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled,
onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp,
onOpenSibling = codeBrowserViewModel::openSibling,
onLoadCounterpart = codeBrowserViewModel::loadCounterpart,
historyState = historyState,
onCommitSelected = historyViewModel::openCommit,
onHistoryNextPage = historyViewModel::nextPage,
onHistoryPreviousPage = historyViewModel::previousPage,
onRetryHistoryList = historyViewModel::retryList,
onRetryHistoryDetail = historyViewModel::retryDetail,
onRetryPatch = historyViewModel::retryPatch,
onFileSelected = historyViewModel::openFileDiff,
onShowAdjacentFile = historyViewModel::openAdjacentFileDiff,
onClearFileSelection = historyViewModel::clearFileSelection,
onHistoryBackToList = historyViewModel::backToList,
onHistoryBack = ::leaveBranchDetail,
onViewWorkItems = { push(Route.WorkItems(route.owner, route.name)) },
onClose = ::leaveBranchDetail,
onAuthenticationExpired = ::signOut,
)
Route.Repositories -> repositoryList()
}
}
LaunchedEffect(viewModel) {
viewModel.browserRequests.collect(::openAuthorizationTab)
@ -293,3 +447,28 @@ class MainActivity : ComponentActivity() {
pendingFavoritePushRoute = Route.BranchDetail(owner, name, CodeReferenceKind.BRANCH.name, ref)
}
}
private val AppTab.route: Route
get() = when (this) {
AppTab.HOME -> Route.Home
AppTab.REPOSITORIES -> Route.Repositories
AppTab.WORK -> Route.Work
AppTab.NOTIFICATIONS -> Route.Notifications
}
private val Route.tab: AppTab?
get() = when (this) {
Route.Home -> AppTab.HOME
Route.Repositories -> AppTab.REPOSITORIES
Route.Work -> AppTab.WORK
Route.Notifications -> AppTab.NOTIFICATIONS
else -> null
}
/** Whether there is a signed-in session, or null when this state does not say either way. */
private fun RepositoryListUiState.sessionReadyOrNull(): Boolean? = when (this) {
RepositoryListUiState.SignedOut, RepositoryListUiState.WaitingForBrowser -> false
RepositoryListUiState.Loading -> null
is RepositoryListUiState.Error -> canLogout
else -> true
}

View File

@ -17,6 +17,19 @@ interface AppSettingsStore {
fun setReactiveAuthRetryEnabled(enabled: Boolean)
fun isFavoritePushNotificationsEnabled(): Boolean
fun setFavoritePushNotificationsEnabled(enabled: Boolean)
// How source code is shown (TASK-053). Wrapping defaults ON: a phone shows about 45 characters
// of code per line, so an unwrapped file needs sideways scrolling on nearly every line. These
// have defaults here so an in-memory test store only has to implement what it exercises.
fun isCodeWordWrapEnabled(): Boolean = true
fun setCodeWordWrapEnabled(enabled: Boolean) = Unit
fun codeFontSizeSp(): Int = DEFAULT_CODE_FONT_SIZE_SP
fun setCodeFontSizeSp(size: Int) = Unit
companion object {
const val DEFAULT_CODE_FONT_SIZE_SP = 13
val CODE_FONT_SIZES_SP = listOf(11, 12, 13, 14, 16)
}
}
class SharedPreferencesAppSettingsStore(context: Context) : AppSettingsStore {
@ -36,7 +49,24 @@ class SharedPreferencesAppSettingsStore(context: Context) : AppSettingsStore {
preferences.edit().putBoolean(KEY_FAVORITE_PUSH_NOTIFICATIONS, enabled).apply()
}
override fun isCodeWordWrapEnabled(): Boolean = preferences.getBoolean(KEY_CODE_WORD_WRAP, true)
override fun setCodeWordWrapEnabled(enabled: Boolean) {
preferences.edit().putBoolean(KEY_CODE_WORD_WRAP, enabled).apply()
}
override fun codeFontSizeSp(): Int =
preferences.getInt(KEY_CODE_FONT_SIZE, AppSettingsStore.DEFAULT_CODE_FONT_SIZE_SP)
.takeIf { it in AppSettingsStore.CODE_FONT_SIZES_SP } ?: AppSettingsStore.DEFAULT_CODE_FONT_SIZE_SP
override fun setCodeFontSizeSp(size: Int) {
if (size !in AppSettingsStore.CODE_FONT_SIZES_SP) return
preferences.edit().putInt(KEY_CODE_FONT_SIZE, size).apply()
}
private companion object {
const val KEY_CODE_WORD_WRAP = "code_word_wrap_enabled"
const val KEY_CODE_FONT_SIZE = "code_font_size_sp"
const val PREFERENCES_NAME = "ubgitea_app_settings"
const val KEY_REACTIVE_AUTH_RETRY = "reactive_auth_retry_enabled"
const val KEY_FAVORITE_PUSH_NOTIFICATIONS = "favorite_push_notifications_enabled"

View 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()
}

View File

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

View File

@ -49,6 +49,15 @@ class GiteaNotificationsApi(
return response.toPage(items, page, pageSize)
}
override suspend fun countUnread(): Int? {
val body = get(apiUrl("api", "v1", "notifications", "new")).body
return try {
gson.fromJson(body, UnreadCountDto::class.java)?.new?.takeIf { it >= 0 }
} catch (_: JsonParseException) {
null
}
}
private fun mapNotification(dto: NotificationDto): NotificationSummary {
val subject = dto.subject ?: throw ApiFailure.InvalidResponse()
val repositoryOwner = dto.repository?.owner?.login.required()
@ -137,6 +146,7 @@ class GiteaNotificationsApi(
private fun Int.safeNextPage(): Int =
if (this == Int.MAX_VALUE) throw ApiFailure.InvalidResponse() else this + 1
private data class UnreadCountDto(val new: Int?)
private data class OwnerDto(val login: String?)
private data class RepositoryDto(val owner: OwnerDto?, val name: String?)
private data class SubjectDto(val title: String?, val url: String?, val type: String?, val state: String?)

View File

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

View File

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

View File

@ -19,6 +19,23 @@ data class DiffRow(val left: DiffLine?, val right: DiffLine?) {
}
}
/**
* One row of a diff as it is shown, which is more than [DiffRow] carries: line numbers on each
* side, and a row of its own for each hunk's header. A header row has neither side and carries
* [hunkLabel] -- the text Git puts after the second `@@`, usually the enclosing function -- which
* is empty when Git supplied none.
*/
data class DiffEntry(
val left: DiffLine?,
val right: DiffLine?,
val leftNumber: Int? = null,
val rightNumber: Int? = null,
val hunkLabel: String? = null,
) {
val isHunkHeader: Boolean get() = hunkLabel != null
val isChange: Boolean get() = left is DiffLine.Removed || right is DiffLine.Added
}
/**
* Splits one file's slice out of a combined multi-file `.diff`/patch body (as returned by
* Gitea's raw diff endpoints) and lays it out as side-by-side rows. Reused across commit and
@ -38,6 +55,31 @@ object UnifiedDiffParser {
return rows
}
/** The same rows as [parseFile], numbered, with a header row in front of every hunk. */
fun parseFileEntries(combinedPatch: String, path: String): List<DiffEntry> {
val section = splitIntoFileSections(combinedPatch).firstOrNull { sectionPath(it) == path }
?: return emptyList()
val headers = section.filter { it.startsWith("@@") }
val entries = mutableListOf<DiffEntry>()
parseHunks(section).forEachIndexed { index, hunk ->
val header = HUNK_HEADER.find(headers.getOrElse(index) { "" })
var leftNumber = header?.groupValues?.get(1)?.toIntOrNull() ?: 1
var rightNumber = header?.groupValues?.get(2)?.toIntOrNull() ?: 1
entries += DiffEntry(null, null, hunkLabel = header?.groupValues?.get(3)?.trim().orEmpty())
hunkToRows(hunk).forEach { row ->
entries += DiffEntry(
left = row.left,
right = row.right,
leftNumber = if (row.left != null) leftNumber++ else null,
rightNumber = if (row.right != null) rightNumber++ else null,
)
}
}
return entries
}
private val HUNK_HEADER = Regex("^@@ -(\\d+)(?:,\\d+)? \\+(\\d+)(?:,\\d+)? @@(.*)$")
private fun splitIntoFileSections(patch: String): List<List<String>> {
val sections = mutableListOf<MutableList<String>>()
for (line in patch.lineSequence()) {

View File

@ -8,4 +8,7 @@ import com.ubisam.ubgitea.domain.model.Page
*/
interface NotificationsRepository {
suspend fun listNotifications(page: Int, pageSize: Int, unreadOnly: Boolean): Page<NotificationSummary>
/** How many notifications are unread; null when that cannot be told. */
suspend fun countUnread(): Int? = null
}

View File

@ -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?
}

View File

@ -2,9 +2,10 @@ package com.ubisam.ubgitea.feature.branchdetail
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
@ -12,33 +13,49 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.domain.history.FileChange
import com.ubisam.ubgitea.domain.model.CommitSummary
import com.ubisam.ubgitea.domain.model.RepositoryPath
import com.ubisam.ubgitea.domain.model.TreeEntry
import com.ubisam.ubgitea.domain.model.CommitSummary
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserScreen
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserUiState
import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification
import com.ubisam.ubgitea.feature.codebrowser.CodeViewerPreferences
import com.ubisam.ubgitea.feature.codebrowser.FileViewerScreen
import com.ubisam.ubgitea.feature.codebrowser.isFileLevel
import com.ubisam.ubgitea.feature.history.FileDiffScreen
import com.ubisam.ubgitea.feature.history.HistoryScreen
import com.ubisam.ubgitea.feature.history.HistoryUiState
import com.ubisam.ubgitea.feature.history.showsFileDiff
import com.ubisam.ubgitea.ui.components.ScreenTopBar
import com.ubisam.ubgitea.ui.components.UBIcon
import kotlinx.coroutines.launch
/**
* One branch/tag/commit ref, shown as two swipeable tabs — Files (the code browser, default) and
* Commit — over the same ref. Each tab keeps its own existing ViewModel and internal drill-down
* (a subfolder, a commit's detail) entirely as before; this screen only adds the tab chrome and
* the pager, and only intercepts "back" once a tab's own internal back-handling has nothing left
* to unwind (see `onCodeBrowserBack`/`onHistoryBack`).
* One branch/tag/commit ref, shown as two swipeable tabs — Files (the directory listing, default)
* and Commit — over the same ref. Each tab keeps its own ViewModel and its own drill-down.
*
* The tabs only ever hold lists. A file, and a file's diff, open full screen in place of the
* tabs: both scroll sideways, and neither belongs inside a pager that swipes sideways. With only
* lists left in it, the pager's swipe is always on.
*/
@Composable
fun BranchDetailScreen(
owner: String,
name: String,
ref: String,
codeBrowserState: CodeBrowserUiState,
codeViewerPreferences: CodeViewerPreferences,
onEntrySelected: (TreeEntry) -> Unit,
onBreadcrumbSelected: (RepositoryPath) -> Unit,
onRetryCodeBrowser: () -> Unit,
onCodeBrowserBack: () -> Unit,
onNavigateImage: (Int) -> Unit,
onSetCodeWordWrap: (Boolean) -> Unit,
onSetCodeFontSize: (Int) -> Unit,
onOpenSibling: (RepositoryPath, String?) -> Unit,
onLoadCounterpart: () -> Unit,
historyState: HistoryUiState,
onCommitSelected: (CommitSummary) -> Unit,
onHistoryNextPage: () -> Unit,
@ -47,73 +64,88 @@ fun BranchDetailScreen(
onRetryHistoryDetail: () -> Unit,
onRetryPatch: () -> Unit,
onFileSelected: (FileChange) -> Unit,
onShowAdjacentFile: (Int) -> Unit,
onClearFileSelection: () -> Unit,
onHistoryBackToList: () -> Unit,
onHistoryBack: () -> Unit,
onViewWorkItems: () -> Unit,
onClose: () -> Unit,
onAuthenticationExpired: () -> Unit,
modifier: Modifier = Modifier,
) {
val pagerState = rememberPagerState(pageCount = { 2 })
val scope = rememberCoroutineScope()
// A page showing horizontally-gestured content — scrollable source text, a diff's before/after
// panels, an image gallery's swipe-to-navigate, or a CSV table's horizontal scroll — has its
// own left/right drag handling that directly conflicts with the pager's own — both are
// horizontal, so a drag meant for that content was instead partly consumed by the pager, which
// is what made scrolling code feel jittery and occasionally scroll text out of view entirely.
// Swiping between tabs only makes sense one level up from any of that anyway (Files' folder
// listing, History's changed-file list), so the pager's own gesture is disabled while either
// tab is showing one of these, and re-enabled once back at that list.
val currentPageShowsScrollableCode = when (pagerState.currentPage) {
0 -> codeBrowserState is CodeBrowserUiState.File &&
(codeBrowserState.content is ContentClassification.Text ||
codeBrowserState.content is ContentClassification.Image ||
codeBrowserState.content is ContentClassification.Csv ||
codeBrowserState.content is ContentClassification.Pdf)
else -> historyState is HistoryUiState.CommitDetailContent && historyState.selectedFile != null
}
Column(modifier.fillMaxSize().statusBarsPadding()) {
TabRow(selectedTabIndex = pagerState.currentPage) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { scope.launch { pagerState.animateScrollToPage(0) } },
text = { Text(stringResource(R.string.branchdetail_tab_files)) },
when {
pagerState.currentPage == FILES_PAGE && codeBrowserState.isFileLevel -> FileViewerScreen(
state = codeBrowserState,
preferences = codeViewerPreferences,
onBack = onCodeBrowserBack,
onRetry = onRetryCodeBrowser,
onAuthenticationExpired = onAuthenticationExpired,
onNavigateImage = onNavigateImage,
onSetWordWrap = onSetCodeWordWrap,
onSetFontSize = onSetCodeFontSize,
onOpenSibling = onOpenSibling,
onOpenEntry = onEntrySelected,
onLoadCounterpart = onLoadCounterpart,
modifier = modifier,
)
pagerState.currentPage == COMMIT_PAGE && historyState is HistoryUiState.CommitDetailContent && historyState.showsFileDiff ->
FileDiffScreen(
state = historyState,
fontSizeSp = codeViewerPreferences.fontSizeSp,
onShowAdjacentFile = onShowAdjacentFile,
onClose = onClearFileSelection,
modifier = modifier,
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
text = { Text(stringResource(R.string.branchdetail_tab_history)) },
)
}
HorizontalPager(
state = pagerState,
userScrollEnabled = !currentPageShowsScrollableCode,
modifier = Modifier.weight(1f),
) { page ->
when (page) {
0 -> CodeBrowserScreen(
state = codeBrowserState,
onEntrySelected = onEntrySelected,
onBreadcrumbSelected = onBreadcrumbSelected,
onRetry = onRetryCodeBrowser,
onBack = onCodeBrowserBack,
onAuthenticationExpired = onAuthenticationExpired,
onNavigateImage = onNavigateImage,
)
else -> HistoryScreen(
state = historyState,
onCommitSelected = onCommitSelected,
onNextPage = onHistoryNextPage,
onPreviousPage = onHistoryPreviousPage,
onRetryList = onRetryHistoryList,
onRetryDetail = onRetryHistoryDetail,
onRetryPatch = onRetryPatch,
onFileSelected = onFileSelected,
onClearFileSelection = onClearFileSelection,
onBackToList = onHistoryBackToList,
onBack = onHistoryBack,
onAuthenticationExpired = onAuthenticationExpired,
)
else -> Column(modifier.fillMaxSize()) {
ScreenTopBar(title = name, subtitle = stringResource(R.string.branchdetail_subtitle, owner, ref), onBack = onClose) {
IconButton(onClick = onViewWorkItems) {
UBIcon(R.drawable.ic_ub_pull_request, stringResource(R.string.repositories_work_items))
}
}
TabRow(selectedTabIndex = pagerState.currentPage, containerColor = MaterialTheme.colorScheme.surface) {
listOf(R.string.branchdetail_tab_files, R.string.branchdetail_tab_history).forEachIndexed { page, label ->
val selected = pagerState.currentPage == page
Tab(
selected = selected,
onClick = { scope.launch { pagerState.animateScrollToPage(page) } },
text = { Text(stringResource(label), fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium) },
)
}
}
HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page ->
when (page) {
FILES_PAGE -> CodeBrowserScreen(
state = codeBrowserState,
onEntrySelected = onEntrySelected,
onBreadcrumbSelected = onBreadcrumbSelected,
onRetry = onRetryCodeBrowser,
onBack = onCodeBrowserBack,
onAuthenticationExpired = onAuthenticationExpired,
)
else -> HistoryScreen(
state = historyState,
onCommitSelected = onCommitSelected,
onNextPage = onHistoryNextPage,
onPreviousPage = onHistoryPreviousPage,
onRetryList = onRetryHistoryList,
onRetryDetail = onRetryHistoryDetail,
onRetryPatch = onRetryPatch,
onFileSelected = onFileSelected,
onBackToList = onHistoryBackToList,
onBack = onHistoryBack,
onAuthenticationExpired = onAuthenticationExpired,
)
}
}
}
}
}
/** Whether the screen is showing a file or a diff full screen, so the app's navigation should step aside. */
fun isReadingFullScreen(codeBrowserState: CodeBrowserUiState, historyState: HistoryUiState): Boolean =
codeBrowserState.isFileLevel || historyState.showsFileDiff
private const val FILES_PAGE = 0
private const val COMMIT_PAGE = 1

View File

@ -29,20 +29,19 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -65,37 +64,42 @@ import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.domain.model.RepositoryPath
import com.ubisam.ubgitea.domain.model.SourceContent
import com.ubisam.ubgitea.domain.model.TreeEntry
import com.ubisam.ubgitea.domain.model.TreeEntryType
import com.ubisam.ubgitea.feature.codebrowser.content.CodeSyntaxColors
import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification
import com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage
import com.ubisam.ubgitea.feature.codebrowser.content.highlightLines
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
import com.ubisam.ubgitea.ui.components.CenteredLoading
import com.ubisam.ubgitea.ui.components.CenteredMessage
import com.ubisam.ubgitea.ui.components.ScreenTopBar
import com.ubisam.ubgitea.ui.components.UBIcon
import java.io.File
import java.io.IOException
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** True while the browser is showing one file (or failing to) rather than a directory listing. */
val CodeBrowserUiState.isFileLevel: Boolean
get() = when (this) {
is CodeBrowserUiState.File, is CodeBrowserUiState.UnsupportedEntry -> true
is CodeBrowserUiState.Loading -> isFile
is CodeBrowserUiState.Error -> isFile
CodeBrowserUiState.Idle, is CodeBrowserUiState.Directory -> false
}
/** The Files tab: a directory listing with the path above it. A file opens in [FileViewerScreen]. */
@Composable
fun CodeBrowserScreen(
state: CodeBrowserUiState,
@ -104,118 +108,181 @@ fun CodeBrowserScreen(
onRetry: () -> Unit,
onBack: () -> Unit,
onAuthenticationExpired: () -> Unit,
onNavigateImage: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = onBack)
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
when (state) {
CodeBrowserUiState.Idle -> Unit
is CodeBrowserUiState.Loading -> {
BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected)
CenterMessage { CircularProgressIndicator() }
}
is CodeBrowserUiState.Directory -> {
BrowserHeader(state.target, state.path, false, onBreadcrumbSelected)
if (state.entries.isEmpty()) {
CenterMessage { Text(stringResource(R.string.codebrowser_directory_empty)) }
} else {
LazyColumn(Modifier.weight(1f)) {
items(state.entries, key = { it.path.value }) { entry ->
EntryRow(entry, onEntrySelected)
}
}
}
}
is CodeBrowserUiState.File -> {
BrowserHeader(state.target, state.path, true, onBreadcrumbSelected)
FileContent(state.content, state.path, state.siblingImagePaths, onNavigateImage)
}
is CodeBrowserUiState.UnsupportedEntry -> {
BrowserHeader(state.target, state.path, true, onBreadcrumbSelected)
CenterMessage {
Text(
stringResource(
if (state.type == TreeEntryType.SUBMODULE) R.string.codebrowser_submodule
else R.string.codebrowser_symlink,
),
)
}
}
is CodeBrowserUiState.Error -> {
BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected)
CenterMessage {
Text(stringResource(state.type.messageResource))
if (state.type == CodeBrowserError.AUTHENTICATION_EXPIRED) {
Button(onClick = onAuthenticationExpired) {
Text(stringResource(R.string.codebrowser_sign_in_again))
}
} else {
Button(onClick = onRetry) { Text(stringResource(R.string.codebrowser_retry)) }
Column(modifier.fillMaxSize()) {
when (state) {
CodeBrowserUiState.Idle -> Unit
is CodeBrowserUiState.Loading -> {
Breadcrumbs(state.path, onBreadcrumbSelected)
CenteredLoading(null)
}
is CodeBrowserUiState.Directory -> {
Breadcrumbs(state.path, onBreadcrumbSelected)
if (state.entries.isEmpty()) {
CenteredMessage(stringResource(R.string.codebrowser_directory_empty))
} else {
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 16.dp)) {
itemsIndexed(state.entries, key = { _, entry -> entry.path.value }) { index, entry ->
EntryRow(entry, index == 0, index == state.entries.lastIndex, onEntrySelected)
}
}
}
}
is CodeBrowserUiState.Error -> {
Breadcrumbs(state.path, onBreadcrumbSelected)
BrowserError(state.type, onRetry, onAuthenticationExpired)
}
// Shown by FileViewerScreen; the tab never receives these.
is CodeBrowserUiState.File, is CodeBrowserUiState.UnsupportedEntry -> Unit
}
}
}
/**
* One file, full screen, outside the Files/Commit pager. Being outside it is the point: code that
* scrolls sideways inside a pager that swipes sideways is what made TASK-048 take three attempts.
*/
@Composable
private fun BrowserHeader(
target: CodeBrowserTarget,
path: RepositoryPath,
leafIsFile: Boolean,
onBreadcrumbSelected: (RepositoryPath) -> Unit,
fun FileViewerScreen(
state: CodeBrowserUiState,
preferences: CodeViewerPreferences,
onBack: () -> Unit,
onRetry: () -> Unit,
onAuthenticationExpired: () -> Unit,
onNavigateImage: (Int) -> Unit,
onSetWordWrap: (Boolean) -> Unit,
onSetFontSize: (Int) -> Unit,
onOpenSibling: (RepositoryPath, String?) -> Unit,
onOpenEntry: (TreeEntry) -> Unit,
onLoadCounterpart: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge)
val refKind = when (target.kind) {
CodeReferenceKind.BRANCH -> stringResource(R.string.codebrowser_ref_branch)
CodeReferenceKind.TAG -> stringResource(R.string.codebrowser_ref_tag)
CodeReferenceKind.COMMIT -> stringResource(R.string.codebrowser_ref_commit)
}
Text(stringResource(R.string.codebrowser_ref, refKind, target.ref.value))
Row(Modifier.horizontalScroll(rememberScrollState()).padding(top = 12.dp)) {
val breadcrumbs = CodeBrowserNavigation.breadcrumbs(path)
breadcrumbs.forEachIndexed { index, crumb ->
if (index > 0) Text(" / ", modifier = Modifier.padding(horizontal = 2.dp))
val canNavigate = !(leafIsFile && index == breadcrumbs.lastIndex)
Text(
crumb.value.substringAfterLast('/').ifEmpty { stringResource(R.string.codebrowser_root) },
color = if (canNavigate) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
modifier = if (canNavigate) {
Modifier.clickable(role = Role.Button) { onBreadcrumbSelected(crumb) }
} else {
Modifier
},
)
}
if (state is CodeBrowserUiState.File && state.content is ContentClassification.Text) {
CodeViewerScreen(
state, state.content, preferences, onBack, onSetWordWrap, onSetFontSize, onOpenSibling, onOpenEntry,
onLoadCounterpart, modifier,
)
return
}
BackHandler(onBack = onBack)
val path = when (state) {
is CodeBrowserUiState.File -> state.path
is CodeBrowserUiState.Loading -> state.path
is CodeBrowserUiState.Error -> state.path
is CodeBrowserUiState.UnsupportedEntry -> state.path
else -> RepositoryPath.Root
}
Column(modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).statusBarsPadding()) {
ScreenTopBar(
title = path.value.substringAfterLast('/'),
subtitle = CodeBrowserNavigation.parent(path).value.ifEmpty { stringResource(R.string.codebrowser_root) },
onBack = onBack,
monospaceTitle = true,
)
when (state) {
is CodeBrowserUiState.File -> FileContent(state.content, state.path, state.siblingImagePaths, onNavigateImage)
is CodeBrowserUiState.Loading -> CenteredLoading(null)
is CodeBrowserUiState.Error -> BrowserError(state.type, onRetry, onAuthenticationExpired)
is CodeBrowserUiState.UnsupportedEntry -> CenteredMessage(
stringResource(
if (state.type == TreeEntryType.SUBMODULE) R.string.codebrowser_submodule else R.string.codebrowser_symlink,
),
)
else -> Unit
}
}
HorizontalDivider()
}
@Composable
private fun EntryRow(entry: TreeEntry, onSelected: (TreeEntry) -> Unit) {
private fun BrowserError(type: CodeBrowserError, onRetry: () -> Unit, onAuthenticationExpired: () -> Unit) {
val expired = type == CodeBrowserError.AUTHENTICATION_EXPIRED
CenteredMessage(
stringResource(type.messageResource),
action = stringResource(if (expired) R.string.codebrowser_sign_in_again else R.string.codebrowser_retry),
onAction = if (expired) onAuthenticationExpired else onRetry,
)
}
@Composable
private fun Breadcrumbs(path: RepositoryPath, onBreadcrumbSelected: (RepositoryPath) -> Unit) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val breadcrumbs = CodeBrowserNavigation.breadcrumbs(path)
breadcrumbs.forEachIndexed { index, crumb ->
if (index > 0) {
Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, modifier = Modifier.padding(horizontal = 6.dp))
}
val isLast = index == breadcrumbs.lastIndex
Text(
crumb.value.substringAfterLast('/').ifEmpty { stringResource(R.string.codebrowser_root) },
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isLast) FontWeight.Bold else FontWeight.Medium,
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
modifier = Modifier
.then(if (!isLast) Modifier.clickable(role = Role.Button) { onBreadcrumbSelected(crumb) } else Modifier)
.padding(vertical = 14.dp),
)
}
}
}
@Composable
private fun EntryRow(entry: TreeEntry, isFirst: Boolean, isLast: Boolean, onSelected: (TreeEntry) -> Unit) {
val kind = when (entry.type) {
TreeEntryType.DIRECTORY -> stringResource(R.string.codebrowser_entry_directory)
TreeEntryType.FILE -> stringResource(R.string.codebrowser_entry_file)
TreeEntryType.SYMLINK -> stringResource(R.string.codebrowser_entry_symlink)
TreeEntryType.SUBMODULE -> stringResource(R.string.codebrowser_entry_submodule)
}
Column(
Modifier.fillMaxWidth().clickable(role = Role.Button) { onSelected(entry) }
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text(entry.name, style = MaterialTheme.typography.titleMedium)
Text(kind, style = MaterialTheme.typography.bodySmall)
val isDirectory = entry.type == TreeEntryType.DIRECTORY
val shape = RoundedCornerShape(
topStart = if (isFirst) 20.dp else 0.dp,
topEnd = if (isFirst) 20.dp else 0.dp,
bottomStart = if (isLast) 20.dp else 0.dp,
bottomEnd = if (isLast) 20.dp else 0.dp,
)
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth()) {
Column {
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Row(
Modifier.fillMaxWidth()
.clickable(role = Role.Button) { onSelected(entry) }
.heightIn(min = 52.dp)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UBIcon(
if (isDirectory) R.drawable.ic_ub_folder else R.drawable.ic_ub_file,
kind,
size = 20.dp,
tint = if (isDirectory) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
entry.name,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (!isDirectory && entry.type != TreeEntryType.FILE) {
Text(kind, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else if (isDirectory) {
UBIcon(R.drawable.ic_ub_chevron_right, null, size = 18.dp, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
HorizontalDivider()
}
@Composable
@Suppress("DEPRECATION")
private fun FileContent(
content: ContentClassification,
path: RepositoryPath,
@ -223,31 +290,8 @@ private fun FileContent(
onNavigateImage: (Int) -> Unit,
) {
when (content) {
is ContentClassification.Text -> {
val clipboard = LocalClipboardManager.current
var wordWrap by remember(path) { mutableStateOf(false) }
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(onClick = { clipboard.setText(AnnotatedString(content.renderedText)) }) {
Text(stringResource(R.string.codebrowser_copy))
}
OutlinedButton(onClick = { wordWrap = !wordWrap }) {
Text(
stringResource(
if (wordWrap) R.string.codebrowser_word_wrap_off else R.string.codebrowser_word_wrap_on,
),
)
}
if (content.isTruncated) Text(stringResource(R.string.codebrowser_text_truncated))
}
CodeBlock(
text = content.renderedText,
language = syntaxLanguageForPath(path),
wordWrap = wordWrap,
modifier = Modifier.fillMaxSize().padding(top = 12.dp),
)
}
}
// Text is shown by CodeViewerScreen and never reaches here.
is ContentClassification.Text -> Unit
is ContentClassification.Image -> {
val index = siblingImagePaths.indexOf(path)
ImageContent(content, index, siblingImagePaths.size, onNavigateImage)
@ -264,104 +308,6 @@ private fun FileContent(
}
}
// Line-numbered, syntax-highlighted source view (see SyntaxHighlighter.kt for what the highlighting
// actually is/isn't).
//
// Four things this shape specifically works around, all found live on-device -- none of them
// visible from build/lint/unit tests, which can't see a real touch gesture or a real wrapped-text
// layout:
// 1. An earlier version paired gutter+code in a LazyColumn with one horizontalScroll per row.
// Horizontal drags simply did nothing -- most likely BranchDetailScreen's HorizontalPager
// (userScrollEnabled is turned off for scrollable code, but apparently still not fully out of
// the nested-scroll picture for a LazyColumn specifically, the way it is for a plain
// Modifier.horizontalScroll chain -- see that file's own comment on this exact class of gesture
// conflict) intercepting the same-orientation drag first. Switched the outer container from
// LazyColumn to a plain Column + Modifier.verticalScroll, matching the shape of the file
// viewer's original, already-known-working implementation -- confirmed live afterward that
// horizontal drag actually moves the code again.
// 2. A version in between paired every row's gutter+code into a single Row (fix for #3 below) but
// gave each row's code Text its own independent Modifier.horizontalScroll -- reasoned at the
// time to be visually invisible since every row's text starts at column 0. Wrong in practice:
// dragging one line only scrolled that one line, leaving the rest of the file visually
// unscrolled and out of step, which reads as broken (a real product report: "선택한 곳만 좌우
// 스크롤 되는건 아닌 것 같다").
// 3. Tried making every row share one hoisted horizontalScrollState instead. That made horizontal
// drag do *nothing at all*, on any row, including the one being dragged -- multiple separate
// Modifier.horizontalScroll gesture-detector regions apparently don't cleanly share one
// ScrollableState the way a single shared scroll *region* does; each row still had its own
// pointer-input node competing over the same state instead of there being one region to drag.
// 4. **Final:** don't try to synchronize N separate horizontalScroll regions at all -- give the
// *whole* code column exactly one horizontalScroll region, wrapping every line's Text stacked in
// a single Column, with the gutter as a separate sibling Column that never scrolls horizontally.
// This only works correctly (gutter height staying matched to code height) as long as no line
// ever wraps to multiple visual rows, which is exactly true whenever this two-Column layout is
// even in play: it's used only when word wrap is off (softWrap = false, so every line is exactly
// one visual row no matter how long). When word wrap is on, wrapping can make a line take
// multiple visual rows, so gutter+code go back to the Row-per-line pairing from fix 2 instead
// (needed for correct number alignment, and horizontalScroll isn't used in this branch anyway --
// wrapped lines have nothing to scroll to).
//
// Not lazy (every line is a real composable, not virtualized) -- the original single-Text version
// wasn't virtualized either, and SourceContentClassifier already bounds how many lines a file view
// ever receives, so this isn't a new performance ceiling.
@Composable
private fun CodeBlock(text: String, language: SyntaxLanguage, wordWrap: Boolean, modifier: Modifier = Modifier) {
val colors = rememberCodeSyntaxColors()
val lines = remember(text) { text.split("\n") }
// Whole-file, once per file: block comments carry state across lines (see tokenizeLines).
val highlighted = remember(lines, language, colors) { highlightLines(lines, language, colors) }
val lineNumberWidth = remember(lines.size) { lines.size.toString().length }
val gutterWidth = (lineNumberWidth * 8 + 12).dp
SelectionContainer(modifier = modifier) {
if (wordWrap) {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
highlighted.forEachIndexed { index, line ->
Row(Modifier.fillMaxWidth()) {
LineNumber(index, gutterWidth, colors)
Text(
text = line,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = colors.text,
softWrap = true,
modifier = Modifier.weight(1f),
)
}
}
}
} else {
Row(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
Column {
lines.indices.forEach { index -> LineNumber(index, gutterWidth, colors) }
}
Column(Modifier.horizontalScroll(rememberScrollState())) {
highlighted.forEach { line ->
Text(
text = line,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = colors.text,
softWrap = false,
)
}
}
}
}
}
}
@Composable
private fun LineNumber(index: Int, width: Dp, colors: CodeSyntaxColors) {
Text(
text = (index + 1).toString(),
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = colors.comment,
textAlign = TextAlign.End,
modifier = Modifier.width(width).padding(end = 8.dp),
)
}
// currentIndex is -1 when this file isn't part of a sibling image gallery (e.g. opened directly
// via a retry rather than from a directory listing) — swipe navigation is simply unavailable then,
// same as any single-image folder (totalCount <= 1).

View File

@ -18,6 +18,7 @@ import com.ubisam.ubgitea.domain.model.TreeEntryType
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification
import com.ubisam.ubgitea.feature.codebrowser.content.SourceContentClassifier
import com.ubisam.ubgitea.feature.codebrowser.content.counterpartOf
import java.util.Collections
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
@ -39,6 +40,14 @@ enum class CodeReferenceKind { BRANCH, TAG, COMMIT }
enum class CodeBrowserError { OFFLINE, AUTHENTICATION_EXPIRED, NOT_FOUND, PERMISSION_DENIED, SERVER }
/** The other half of a header/source pair, fetched only when a window wide enough to show both asks for it. */
sealed interface CounterpartUiState {
data object NotLoaded : CounterpartUiState
data object Loading : CounterpartUiState
data class Loaded(val content: ContentClassification) : CounterpartUiState
data object Failed : CounterpartUiState
}
sealed interface CodeBrowserUiState {
data object Idle : CodeBrowserUiState
data class Loading(
@ -62,6 +71,16 @@ sealed interface CodeBrowserUiState {
// second directory fetch. Empty for anything opened outside a directory listing (e.g. a
// retry from Error) or when the containing folder has no other images.
val siblingImagePaths: List<RepositoryPath> = emptyList(),
// Everything in the directory this file was opened from, in listing order: what a wide
// window's file pane shows beside the code, and where a C/C++ file's counterpart is
// looked for. Empty when the file was not opened from a directory listing.
val siblingEntries: List<TreeEntry> = emptyList(),
// The header of this source file or the source of this header, when the directory has one.
val counterpartPath: RepositoryPath? = null,
val counterpart: CounterpartUiState = CounterpartUiState.NotLoaded,
// A symbol to bring into view once the file is shown: set when the file was reached by
// following a symbol from its counterpart.
val focusSymbol: String? = null,
) : CodeBrowserUiState
data class UnsupportedEntry(
val target: CodeBrowserTarget,
@ -93,6 +112,7 @@ class CodeBrowserViewModel(
private val mutableState = MutableStateFlow<CodeBrowserUiState>(CodeBrowserUiState.Idle)
val state: StateFlow<CodeBrowserUiState> = mutableState.asStateFlow()
private var operation: Job? = null
private var counterpartOperation: Job? = null
fun open(target: CodeBrowserTarget) = loadDirectory(target, RepositoryPath.Root)
@ -100,14 +120,7 @@ class CodeBrowserViewModel(
val target = currentTarget() ?: return
when (entry.type) {
TreeEntryType.DIRECTORY -> loadDirectory(target, entry.path)
TreeEntryType.FILE -> {
val siblingImagePaths = (mutableState.value as? CodeBrowserUiState.Directory)
?.entries
?.filter { it.type == TreeEntryType.FILE && it.path.hasImageExtension() }
?.map { it.path }
?: emptyList()
loadFile(target, entry.path, siblingImagePaths)
}
TreeEntryType.FILE -> loadFile(target, entry.path, currentSiblingEntries())
TreeEntryType.SYMLINK, TreeEntryType.SUBMODULE -> {
operation?.cancel()
mutableState.value = CodeBrowserUiState.UnsupportedEntry(target, entry.path, entry.type)
@ -124,7 +137,38 @@ class CodeBrowserViewModel(
if (index == -1) return
val newIndex = index + delta
if (newIndex !in siblings.indices) return
loadFile(current.target, siblings[newIndex], siblings)
loadFile(current.target, siblings[newIndex], current.siblingEntries)
}
/** Opens another file of the directory the current file came from, optionally at a symbol. */
fun openSibling(path: RepositoryPath, focusSymbol: String? = null) {
val current = mutableState.value as? CodeBrowserUiState.File ?: return
if (path == current.path) return
loadFile(current.target, path, current.siblingEntries, focusSymbol)
}
/** Fetches the current file's counterpart so both can be shown side by side. */
fun loadCounterpart() {
val current = mutableState.value as? CodeBrowserUiState.File ?: return
val path = current.counterpartPath ?: return
if (current.counterpart is CounterpartUiState.Loading || current.counterpart is CounterpartUiState.Loaded) return
mutableState.value = current.copy(counterpart = CounterpartUiState.Loading)
counterpartOperation?.cancel()
counterpartOperation = viewModelScope.launch {
val result = try {
val classification = withContext(defaultDispatcher) {
classifier.classify(browser.getFile(current.target.repository, current.target.ref, path).content, path)
}
CounterpartUiState.Loaded(classification)
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
CounterpartUiState.Failed
}
val latest = mutableState.value as? CodeBrowserUiState.File ?: return@launch
if (latest.path == current.path && latest.counterpartPath == path) {
mutableState.value = latest.copy(counterpart = result)
}
}
}
fun openBreadcrumb(path: RepositoryPath) {
@ -150,9 +194,16 @@ class CodeBrowserViewModel(
fun close() {
operation?.cancel()
operation = null
counterpartOperation?.cancel()
mutableState.value = CodeBrowserUiState.Idle
}
private fun currentSiblingEntries(): List<TreeEntry> = when (val current = mutableState.value) {
is CodeBrowserUiState.Directory -> current.entries
is CodeBrowserUiState.File -> current.siblingEntries
else -> emptyList()
}
// Network parsing, directory sort/dedupe, and file classification are CPU/allocation-heavy
// enough on large trees or large files that they're moved off viewModelScope's Main dispatcher
// here, rather than left to run on the UI thread after the network suspend point resumes.
@ -174,15 +225,26 @@ class CodeBrowserViewModel(
private fun loadFile(
target: CodeBrowserTarget,
path: RepositoryPath,
siblingImagePaths: List<RepositoryPath> = emptyList(),
siblingEntries: List<TreeEntry> = emptyList(),
focusSymbol: String? = null,
) = startOperation {
counterpartOperation?.cancel()
mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = true)
try {
val classification = withContext(defaultDispatcher) {
val file = browser.getFile(target.repository, target.ref, path)
classifier.classify(file.content, path)
}
mutableState.value = CodeBrowserUiState.File(target, path, classification, siblingImagePaths)
val siblingFiles = siblingEntries.filter { it.type == TreeEntryType.FILE }.map(TreeEntry::path)
mutableState.value = CodeBrowserUiState.File(
target = target,
path = path,
content = classification,
siblingImagePaths = siblingFiles.filter { it.hasImageExtension() },
siblingEntries = siblingEntries,
counterpartPath = counterpartOf(path, siblingFiles),
focusSymbol = focusSymbol,
)
} catch (failure: Throwable) {
handleFailure(target, path, failure, isFile = true)
}

View File

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

View File

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

View File

@ -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()

View File

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

View File

@ -1,13 +1,13 @@
package com.ubisam.ubgitea.feature.codebrowser.content
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import com.ubisam.ubgitea.domain.model.RepositoryPath
@ -48,36 +48,48 @@ fun syntaxLanguageForPath(path: RepositoryPath): SyntaxLanguage {
data class CodeSyntaxColors(
val text: Color,
val keyword: Color,
val controlKeyword: Color,
val type: Color,
val method: Color,
val string: Color,
val comment: Color,
val number: Color,
val preprocessor: Color,
)
// Colors are fixed (not derived from the app's own sky-blue Material theme) and only switch
// between two hand-picked sets for light/dark -- source code highlighting has its own well-known
// conventions (keyword blue, string orange/red, comment green) independent of a given app's brand
// color, and reusing brand colors here would read as less familiar, not more polished.
// Visual Studio's default colors (TASK-053; the team reads C# and C++ in Visual Studio, so the same
// code should look the same here). Token colors are fixed rather than derived from the app's brand
// palette -- source highlighting has its own conventions -- but the plain-text color and the
// background are the app theme's own, so the code pane is not a differently-themed island. Two
// light values are darker than Visual Studio's originals (type #2B91AF, comment #008000), which
// fall below 4.5:1 on white at code size.
@Composable
fun rememberCodeSyntaxColors(): CodeSyntaxColors {
val dark = isSystemInDarkTheme()
return remember(dark) {
val text = MaterialTheme.colorScheme.onSurface
return remember(dark, text) {
if (dark) {
CodeSyntaxColors(
text = Color(0xFFD4D4D4),
text = text,
keyword = Color(0xFF569CD6),
string = Color(0xFFCE9178),
comment = Color(0xFF6A9955),
controlKeyword = Color(0xFFD8A0DF),
type = Color(0xFF4EC9B0),
method = Color(0xFFDCDCAA),
string = Color(0xFFD69D85),
comment = Color(0xFF57A64A),
number = Color(0xFFB5CEA8),
preprocessor = Color(0xFF9B9B9B),
)
} else {
CodeSyntaxColors(
text = Color(0xFF1F1F1F),
text = text,
keyword = Color(0xFF0000FF),
controlKeyword = Color(0xFF8F08C4),
type = Color(0xFF1F7A99),
method = Color(0xFF74531F),
string = Color(0xFFA31515),
comment = Color(0xFF008000),
number = Color(0xFF098658),
comment = Color(0xFF007A00),
number = text,
preprocessor = Color(0xFF6B6B6B),
)
}
@ -91,8 +103,21 @@ private data class LangSpec(
val blockComment: Boolean = false,
val xmlComment: Boolean = false,
val preprocessor: Boolean = false,
/** Whether identifiers that are not keywords are told apart as types and methods. */
val identifiers: Boolean = false,
)
// Flow-control words Visual Studio colors differently from other keywords. Applied only to words
// that are keywords of the language at hand, so listing a word another language lacks is harmless.
private val CONTROL_KEYWORDS = setOf(
"if", "else", "switch", "case", "default", "for", "foreach", "while", "do", "break", "continue",
"return", "throw", "try", "catch", "finally", "goto", "yield", "when", "co_return", "co_yield",
"co_await",
)
// A line that only names a namespace or package: its identifiers are neither types nor methods.
private val NAMESPACE_LINE = Regex("^\\s*(?:using|import|package|namespace)\\s+(?:static\\s+)?[\\w.:*]+(?:\\s*=\\s*[\\w.:<>, ]+)?\\s*;?\\s*\\{?\\s*$")
private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
SyntaxLanguage.KOTLIN to LangSpec(
keywords = setOf(
@ -106,6 +131,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
),
lineComment = "//",
blockComment = true,
identifiers = true,
),
SyntaxLanguage.JAVA to LangSpec(
keywords = setOf(
@ -118,6 +144,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
),
lineComment = "//",
blockComment = true,
identifiers = true,
),
SyntaxLanguage.CSHARP to LangSpec(
keywords = setOf(
@ -132,6 +159,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
),
lineComment = "//",
blockComment = true,
identifiers = true,
preprocessor = true,
),
SyntaxLanguage.CPP to LangSpec(
@ -150,6 +178,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
),
lineComment = "//",
blockComment = true,
identifiers = true,
preprocessor = true,
),
SyntaxLanguage.JAVASCRIPT to LangSpec(
@ -163,6 +192,7 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
),
lineComment = "//",
blockComment = true,
identifiers = true,
),
SyntaxLanguage.PYTHON to LangSpec(
keywords = setOf(
@ -262,9 +292,14 @@ internal fun tokenizeLines(lines: List<String>, language: SyntaxLanguage): List<
}
}
}
val plainIdentifiers = !spec.identifiers || NAMESPACE_LINE.matches(line)
var previousWord: String? = null
for (match in pattern.findAll(line, from)) {
val text = match.value
val role = classify(text, spec) ?: continue
val role = classify(text, spec)
?: if (plainIdentifiers) null else identifierRole(line, match.range, previousWord)
if (text.first().isLetter() || text.first() == '_') previousWord = text
if (role == null) continue
tokens += SyntaxToken(match.range.first, match.range.last + 1, role)
if (role == TokenRole.COMMENT) open = unterminatedBlockComment(text, spec)
}
@ -272,6 +307,27 @@ internal fun tokenizeLines(lines: List<String>, language: SyntaxLanguage): List<
}
}
/**
* Tells a type or a method from a plain identifier by how it is written and what surrounds it --
* there is no symbol table here, so this is convention, not knowledge. A name followed by `(` is a
* method, unless `new` precedes it (a constructor call names a type). A capitalized name is a type,
* unless it is reached through `.` (a property or member) or is written all in capitals (a
* constant or macro). A name with the `m_` or `_` field prefix is always plain.
*/
private fun identifierRole(line: String, range: IntRange, previousWord: String?): TokenRole? {
val text = line.substring(range.first, range.last + 1)
if (text.startsWith("m_") || text.startsWith("_")) return null
val next = line.drop(range.last + 1).firstOrNull { !it.isWhitespace() }
val before = line.take(range.first).lastOrNull { !it.isWhitespace() }
if (next == '(') return if (previousWord == "new") TokenRole.TYPE else TokenRole.METHOD
val capitalized = text.first().isUpperCase() && text.any(Char::isLowerCase)
return when {
capitalized && before != '.' -> TokenRole.TYPE
text.endsWith("_t") && text.length > 2 -> TokenRole.TYPE
else -> null
}
}
private fun unterminatedBlockComment(text: String, spec: LangSpec): BlockComment? {
val block = when {
spec.blockComment && text.startsWith(BlockComment.C_STYLE.open) -> BlockComment.C_STYLE
@ -295,7 +351,7 @@ private fun annotate(line: String, tokens: List<SyntaxToken>, colors: CodeSyntax
var lastIndex = 0
for (token in tokens) {
if (token.start > lastIndex) append(line.substring(lastIndex, token.start))
withStyle(SpanStyle(color = token.role.colorOf(colors), fontWeight = token.role.weight)) {
withStyle(SpanStyle(color = token.role.colorOf(colors))) {
append(line.substring(token.start, token.end))
}
lastIndex = token.end
@ -303,12 +359,15 @@ private fun annotate(line: String, tokens: List<SyntaxToken>, colors: CodeSyntax
if (lastIndex < line.length) append(line.substring(lastIndex))
}
internal enum class TokenRole(val weight: FontWeight?) {
COMMENT(null),
STRING(null),
NUMBER(null),
KEYWORD(FontWeight.SemiBold),
PREPROCESSOR(null),
internal enum class TokenRole {
COMMENT,
STRING,
NUMBER,
KEYWORD,
CONTROL_KEYWORD,
TYPE,
METHOD,
PREPROCESSOR,
;
fun colorOf(colors: CodeSyntaxColors): Color = when (this) {
@ -316,6 +375,9 @@ internal enum class TokenRole(val weight: FontWeight?) {
STRING -> colors.string
NUMBER -> colors.number
KEYWORD -> colors.keyword
CONTROL_KEYWORD -> colors.controlKeyword
TYPE -> colors.type
METHOD -> colors.method
PREPROCESSOR -> colors.preprocessor
}
}
@ -325,7 +387,7 @@ private fun classify(text: String, spec: LangSpec): TokenRole? = when {
spec.xmlComment && text.startsWith("<!--") -> TokenRole.COMMENT
text.startsWith("\"") || text.startsWith("'") -> TokenRole.STRING
text.first().isDigit() -> TokenRole.NUMBER
isKeyword(text, spec) -> TokenRole.KEYWORD
isKeyword(text, spec) -> if (spec.identifiers && text in CONTROL_KEYWORDS) TokenRole.CONTROL_KEYWORD else TokenRole.KEYWORD
else -> null
}

View File

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

View File

@ -1,70 +1,66 @@
package com.ubisam.ubgitea.feature.history
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollable
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.domain.history.CommitDetail
import com.ubisam.ubgitea.domain.history.DiffLine
import com.ubisam.ubgitea.domain.history.DiffRow
import com.ubisam.ubgitea.domain.history.FileChange
import com.ubisam.ubgitea.domain.history.FileChangeStatus
import com.ubisam.ubgitea.domain.model.CommitSummary
import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.ui.SimplePaginationFooter
import com.ubisam.ubgitea.ui.components.ActivityTone
import com.ubisam.ubgitea.ui.components.Avatar
import com.ubisam.ubgitea.ui.components.CenteredMessage
import com.ubisam.ubgitea.ui.components.ListCard
import com.ubisam.ubgitea.ui.components.MonoChip
import com.ubisam.ubgitea.ui.components.SectionTitle
import com.ubisam.ubgitea.ui.components.StatusBadge
import com.ubisam.ubgitea.ui.formatInstant
import kotlin.math.roundToInt
import com.ubisam.ubgitea.ui.formatRelative
import kotlinx.coroutines.delay
/** True while one file's diff is open: shown full screen by [FileDiffScreen], outside the tab pager. */
val HistoryUiState.showsFileDiff: Boolean
get() = this is HistoryUiState.CommitDetailContent && selectedFile != null
/** The Commit tab: the commit list, and one commit's changed files. A file's diff opens in [FileDiffScreen]. */
@Composable
fun HistoryScreen(
state: HistoryUiState,
@ -75,131 +71,100 @@ fun HistoryScreen(
onRetryDetail: () -> Unit,
onRetryPatch: () -> Unit,
onFileSelected: (FileChange) -> Unit,
onClearFileSelection: () -> Unit,
onBackToList: () -> Unit,
onBack: () -> Unit,
onAuthenticationExpired: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
when (state) {
HistoryUiState.Idle -> Unit
is HistoryUiState.Loading -> {
BackHandler(onBack = onBack)
Header(state.target)
CenterMessage { EstimatedProgressIndicator() }
}
is HistoryUiState.CommitList -> {
BackHandler(onBack = onBack)
Header(state.target)
if (state.commits.isEmpty()) {
CenterMessage { Text(stringResource(R.string.history_empty)) }
} else {
LazyColumn(Modifier.weight(1f)) {
items(state.commits, key = CommitSummary::id) { commit ->
CommitRow(commit) { onCommitSelected(commit) }
}
}
}
PageFooter(state.page, state.page > 1, state.hasNextPage, onPreviousPage, onNextPage)
}
is HistoryUiState.CommitDetailLoading -> {
BackHandler(onBack = onBackToList)
Header(state.target)
CenterMessage { EstimatedProgressIndicator() }
}
is HistoryUiState.CommitDetailContent -> {
CommitDetailScreen(state, onRetryPatch, onFileSelected, onClearFileSelection, onBackToList)
}
is HistoryUiState.Error -> {
BackHandler(onBack = onBack)
Header(state.target)
CenterMessage {
Text(stringResource(state.type.messageResource))
if (state.type == HistoryError.AUTHENTICATION_EXPIRED) {
Button(onClick = onAuthenticationExpired) { Text(stringResource(R.string.history_sign_in_again)) }
} else {
Button(onClick = if (state.context == HistoryErrorContext.LIST) onRetryList else onRetryDetail) {
Text(stringResource(R.string.history_retry))
}
Column(modifier.fillMaxSize()) {
when (state) {
HistoryUiState.Idle -> Unit
is HistoryUiState.Loading -> {
BackHandler(onBack = onBack)
CenterColumn { EstimatedProgressIndicator() }
}
is HistoryUiState.CommitList -> {
BackHandler(onBack = onBack)
if (state.commits.isEmpty()) {
CenteredMessage(stringResource(R.string.history_empty))
} else {
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp)) {
itemsIndexed(state.commits, key = { _, commit -> commit.id }) { index, commit ->
CommitRow(commit, index == 0, index == state.commits.lastIndex) { onCommitSelected(commit) }
}
}
SimplePaginationFooter(
previousLabel = stringResource(R.string.history_previous_page),
pageLabel = stringResource(R.string.history_page_number, state.page),
nextLabel = stringResource(R.string.history_next_page),
hasPrevious = state.page > 1,
hasNext = state.hasNextPage,
onPrevious = onPreviousPage,
onNext = onNextPage,
)
}
}
is HistoryUiState.CommitDetailLoading -> {
BackHandler(onBack = onBackToList)
CenterColumn { EstimatedProgressIndicator() }
}
is HistoryUiState.CommitDetailContent -> {
BackHandler(onBack = onBackToList)
CommitDetailView(state, onRetryPatch, onFileSelected)
}
is HistoryUiState.Error -> {
BackHandler(onBack = onBack)
val expired = state.type == HistoryError.AUTHENTICATION_EXPIRED
CenteredMessage(
stringResource(state.type.messageResource),
action = stringResource(if (expired) R.string.history_sign_in_again else R.string.history_retry),
onAction = when {
expired -> onAuthenticationExpired
state.context == HistoryErrorContext.LIST -> onRetryList
else -> onRetryDetail
},
)
}
}
}
}
@Composable
private fun Header(target: HistoryTarget) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge)
Text(stringResource(R.string.history_ref, target.ref.value), style = MaterialTheme.typography.bodyMedium)
}
HorizontalDivider()
}
@Composable
private fun CommitRow(commit: CommitSummary, onSelected: () -> Unit) {
Column(
Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text(commit.shortId, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
Text(commit.message, style = MaterialTheme.typography.titleMedium, maxLines = 2)
Text(
stringResource(R.string.history_commit_by_at, commit.authorName, formatInstant(commit.authoredAtEpochMillis)),
style = MaterialTheme.typography.bodySmall,
)
}
HorizontalDivider()
}
// Below the "medium" width breakpoint (Android's own guidance for list-detail layouts, and
// roughly a phone in any orientation), a selected file replaces the list as its own screen. At
// or above it — a tablet, or a foldable's inner screen unfolded — the file list and the selected
// file's diff show side by side at once, since there is room for both.
private val WideLayoutBreakpoint = 600.dp
@Composable
private fun CommitDetailScreen(
state: HistoryUiState.CommitDetailContent,
onRetryPatch: () -> Unit,
onFileSelected: (FileChange) -> Unit,
onClearFileSelection: () -> Unit,
onBackToList: () -> Unit,
) {
BoxWithConstraints(Modifier.fillMaxSize()) {
if (maxWidth >= WideLayoutBreakpoint) {
BackHandler(onBack = onBackToList)
Row(Modifier.fillMaxSize()) {
Column(Modifier.weight(1f)) {
Header(state.target)
CommitDetailView(state, onRetryPatch, onFileSelected)
}
VerticalDivider()
Column(Modifier.weight(1f)) {
val selected = state.selectedFile
if (selected != null) {
FileDiffView(selected)
} else {
CenterMessage { Text(stringResource(R.string.history_file_diff_select_prompt)) }
private fun CommitRow(commit: CommitSummary, isFirst: Boolean, isLast: Boolean, onSelected: () -> Unit) {
val shape = RoundedCornerShape(
topStart = if (isFirst) 20.dp else 0.dp,
topEnd = if (isFirst) 20.dp else 0.dp,
bottomStart = if (isLast) 20.dp else 0.dp,
bottomEnd = if (isLast) 20.dp else 0.dp,
)
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth()) {
Column {
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Row(
Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Avatar(commit.authorName)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
commit.message.lineSequence().firstOrNull().orEmpty(),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MonoChip(commit.shortId, icon = R.drawable.ic_ub_commit)
Text(
stringResource(R.string.history_commit_by_at, commit.authorName, formatRelative(commit.authoredAtEpochMillis)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
} else if (state.selectedFile != null) {
BackHandler(onBack = onClearFileSelection)
Column(Modifier.fillMaxSize()) {
Header(state.target)
FileDiffView(state.selectedFile)
}
} else {
BackHandler(onBack = onBackToList)
Column(Modifier.fillMaxSize()) {
Header(state.target)
CommitDetailView(state, onRetryPatch, onFileSelected)
}
}
}
}
@ -211,224 +176,122 @@ private fun CommitDetailView(
onFileSelected: (FileChange) -> Unit,
) {
val canOpenFileDiff = state.patch is PatchUiState.Loaded
LazyColumn(Modifier.fillMaxSize()) {
item {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text(state.commit.id, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
Text(state.commit.message, style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.history_commit_by_at, state.commit.authorName, formatInstant(state.commit.authoredAtEpochMillis)),
style = MaterialTheme.typography.bodySmall,
)
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
item(key = "commit") {
ListCard(Modifier.padding(horizontal = 20.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
SelectionContainer { Text(state.commit.message.trim(), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MonoChip(state.commit.shortId, icon = R.drawable.ic_ub_commit)
Text(
stringResource(R.string.history_commit_by_at, state.commit.authorName, formatInstant(state.commit.authoredAtEpochMillis)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
items(state.files, key = FileChange::path) { file ->
item(key = "files-title") { SectionTitle(stringResource(R.string.history_files_changed, state.files.size)) }
itemsIndexed(state.files, key = { _, file -> file.path }) { index, file ->
FileChangeRow(
file,
Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
isFirst = index == 0,
isLast = index == state.files.lastIndex,
onSelected = if (canOpenFileDiff) ({ onFileSelected(file) }) else null,
)
}
item { HorizontalDivider(Modifier.padding(vertical = 12.dp)) }
item { PatchSection(state.patch, onRetryPatch) }
item(key = "patch-title") { SectionTitle(stringResource(R.string.history_patch_title), Modifier.padding(top = 8.dp)) }
item(key = "patch") { PatchSection(state.patch, onRetryPatch) }
}
}
@Composable
private fun FileChangeRow(file: FileChange, modifier: Modifier = Modifier, onSelected: (() -> Unit)? = null) {
Column(
if (onSelected != null) modifier.clickable(role = Role.Button, onClick = onSelected) else modifier,
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(file.status.labelResource), style = MaterialTheme.typography.bodySmall)
Text(file.path, style = MaterialTheme.typography.bodySmall, maxLines = 1)
}
file.previousPath?.let {
Text(stringResource(R.string.history_renamed_from, it), style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun FileDiffView(state: HistoryUiState.SelectedFileDiff) {
Column(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text(state.file.path, style = MaterialTheme.typography.titleMedium)
if (state.isTruncated) Text(stringResource(R.string.history_patch_truncated), style = MaterialTheme.typography.bodySmall)
}
if (state.rows.isEmpty()) {
CenterMessage { Text(stringResource(R.string.history_file_diff_unavailable)) }
} else {
// Both lists always have exactly state.rows.size items (a missing side just renders
// a blank line for that index), so mirroring first-visible-index/offset between them
// keeps the two panels showing the same rows without needing per-line height math.
val beforeState = rememberLazyListState()
val afterState = rememberLazyListState()
// One shared horizontal scroll offset for every line in both panels, driven manually
// instead of via Modifier.horizontalScroll per row: sharing a single ScrollState across
// many differently-sized Text rows makes each row's layout pass overwrite the shared
// max-scroll bound with its own width, so as the list recomposes different-length lines
// the bound (and therefore the visible offset) kept getting stomped mid-drag, snapping
// the text back and forth. Computing one fixed bound up front and applying a plain
// Modifier.offset (clamped by hand) avoids that thrashing entirely.
val diffLineStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
val textMeasurer = rememberTextMeasurer()
val maxHorizontalOffsetPx = remember(state.rows, diffLineStyle) {
val longestLineChars = state.rows.maxOfOrNull { row ->
maxOf(row.left?.text?.length ?: 0, row.right?.text?.length ?: 0)
} ?: 0
if (longestLineChars == 0) {
0f
} else {
// Monospace, so any character stands in for the widest line's actual glyphs;
// +2 accounts for the "- "/"+ "/" " prefix DiffSideLine prepends below.
textMeasurer.measure("0".repeat(longestLineChars + 2), diffLineStyle).size.width.toFloat()
private fun FileChangeRow(file: FileChange, isFirst: Boolean, isLast: Boolean, onSelected: (() -> Unit)?) {
val shape = RoundedCornerShape(
topStart = if (isFirst) 20.dp else 0.dp,
topEnd = if (isFirst) 20.dp else 0.dp,
bottomStart = if (isLast) 20.dp else 0.dp,
bottomEnd = if (isLast) 20.dp else 0.dp,
)
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
Column {
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Column(
Modifier.fillMaxWidth()
.then(if (onSelected != null) Modifier.clickable(role = Role.Button, onClick = onSelected) else Modifier)
.heightIn(min = 52.dp)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatusBadge(stringResource(file.status.labelResource), file.status.tone)
Text(
file.path.substringAfterLast('/'),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
val horizontalOffsetPx = remember(state.rows) { mutableFloatStateOf(0f) }
// Dragging left must reveal what's further along the line (offset increases), matching
// every other horizontally-scrollable surface on Android — verified live to be backwards
// with a plain "+ delta", so the sign is flipped here instead.
val horizontalScrollableState = rememberScrollableState { delta ->
val previousOffset = horizontalOffsetPx.floatValue
val newOffset = (previousOffset - delta).coerceIn(0f, maxHorizontalOffsetPx)
horizontalOffsetPx.floatValue = newOffset
previousOffset - newOffset
}
LaunchedEffect(beforeState, afterState) {
snapshotFlow { beforeState.firstVisibleItemIndex to beforeState.firstVisibleItemScrollOffset }
.collect { (index, offset) ->
if (afterState.firstVisibleItemIndex != index || afterState.firstVisibleItemScrollOffset != offset) {
afterState.scrollToItem(index, offset)
}
}
}
LaunchedEffect(beforeState, afterState) {
snapshotFlow { afterState.firstVisibleItemIndex to afterState.firstVisibleItemScrollOffset }
.collect { (index, offset) ->
if (beforeState.firstVisibleItemIndex != index || beforeState.firstVisibleItemScrollOffset != offset) {
beforeState.scrollToItem(index, offset)
}
}
}
Text(
stringResource(R.string.history_file_diff_before),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
LazyColumn(Modifier.weight(1f), state = beforeState) {
items(state.rows.size) { index ->
DiffSideLine(state.rows[index].left, state.rows[index].isGap, horizontalScrollableState, horizontalOffsetPx)
}
}
HorizontalDivider(thickness = 2.dp)
Text(
stringResource(R.string.history_file_diff_after),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
LazyColumn(Modifier.weight(1f), state = afterState) {
items(state.rows.size) { index ->
DiffSideLine(state.rows[index].right, state.rows[index].isGap, horizontalScrollableState, horizontalOffsetPx)
Text(
file.path,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.StartEllipsis,
)
file.previousPath?.let {
Text(
stringResource(R.string.history_renamed_from, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
private val DiffRow.isGap: Boolean
get() = left == null && right == null
@Composable
private fun DiffSideLine(
line: DiffLine?,
isGap: Boolean,
scrollableState: ScrollableState,
horizontalOffsetPx: State<Float>,
) {
if (isGap) {
Text(
"⋯",
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall,
)
return
}
val color = when (line) {
is DiffLine.Removed -> MaterialTheme.colorScheme.error
is DiffLine.Added -> DiffAddedColor
else -> Color.Unspecified
}
val prefix = when (line) {
is DiffLine.Removed -> "- "
is DiffLine.Added -> "+ "
else -> " "
}
Box(
Modifier.fillMaxWidth().clipToBounds()
.scrollable(state = scrollableState, orientation = Orientation.Horizontal)
.padding(vertical = 2.dp),
) {
Text(
prefix + (line?.text ?: ""),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
color = color,
softWrap = false,
modifier = Modifier
.offset { IntOffset(-horizontalOffsetPx.value.roundToInt(), 0) }
.padding(horizontal = 16.dp),
)
}
}
// Material3 has no built-in "success" role to match colorScheme.error, so this is hardcoded.
private val DiffAddedColor = Color(0xFF2E7D32)
@Composable
private fun PatchSection(patch: PatchUiState, onRetry: () -> Unit) {
when (patch) {
PatchUiState.Loading -> CenterMessage { EstimatedProgressIndicator() }
is PatchUiState.Loaded -> PatchText(patch.text, patch.isTruncated)
PatchUiState.Failed -> CenterMessage {
Text(stringResource(R.string.history_patch_failed))
Button(onClick = onRetry) { Text(stringResource(R.string.history_retry)) }
PatchUiState.Loading -> CenterColumn(Modifier.padding(vertical = 24.dp)) { EstimatedProgressIndicator() }
is PatchUiState.Loaded -> ListCard(Modifier.padding(horizontal = 20.dp)) {
Column(Modifier.padding(16.dp)) {
if (patch.isTruncated) {
Text(
stringResource(R.string.history_patch_truncated),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
}
SelectionContainer {
Text(
patch.text,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.horizontalScroll(rememberScrollState()),
)
}
}
}
PatchUiState.Failed -> CenteredMessage(
stringResource(R.string.history_patch_failed),
Modifier.padding(vertical = 8.dp),
action = stringResource(R.string.history_retry),
onAction = onRetry,
)
}
}
@Composable
private fun PatchText(text: String, isTruncated: Boolean) {
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
if (isTruncated) Text(stringResource(R.string.history_patch_truncated))
SelectionContainer {
Text(
text,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.horizontalScroll(rememberScrollState()),
)
}
}
}
@Composable
private fun PageFooter(page: Int, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) {
SimplePaginationFooter(
previousLabel = stringResource(R.string.history_previous_page),
pageLabel = stringResource(R.string.history_page_number, page),
nextLabel = stringResource(R.string.history_next_page),
hasPrevious = hasPrevious,
hasNext = hasNext,
onPrevious = onPrevious,
onNext = onNext,
)
}
@Composable
private fun CenterMessage(content: @Composable ColumnScope.() -> Unit) {
internal fun CenterColumn(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier.fillMaxSize().padding(24.dp),
modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
content = content,
@ -459,12 +322,12 @@ private fun EstimatedProgressIndicator() {
Text(
stringResource(R.string.history_loading_progress, (progress * 100).toInt()),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp),
modifier = Modifier.padding(top = 8.dp).semantics { heading() },
)
}
private val FileChangeStatus.labelResource: Int
get() = when (this) {
internal val FileChangeStatus.labelResource: Int
@StringRes get() = when (this) {
FileChangeStatus.ADDED -> R.string.history_status_added
FileChangeStatus.MODIFIED -> R.string.history_status_modified
FileChangeStatus.DELETED -> R.string.history_status_deleted
@ -472,8 +335,17 @@ private val FileChangeStatus.labelResource: Int
FileChangeStatus.OTHER -> R.string.history_status_other
}
private val HistoryError.messageResource: Int
internal val FileChangeStatus.tone: ActivityTone
get() = when (this) {
FileChangeStatus.ADDED -> ActivityTone.SUCCESS
FileChangeStatus.MODIFIED -> ActivityTone.BRAND
FileChangeStatus.DELETED -> ActivityTone.DANGER
FileChangeStatus.RENAMED -> ActivityTone.MERGED
FileChangeStatus.OTHER -> ActivityTone.NEUTRAL
}
private val HistoryError.messageResource: Int
@StringRes get() = when (this) {
HistoryError.OFFLINE -> R.string.history_offline
HistoryError.AUTHENTICATION_EXPIRED -> R.string.history_auth_expired
HistoryError.PERMISSION_DENIED -> R.string.history_permission_denied

View File

@ -12,6 +12,7 @@ import com.ubisam.ubgitea.core.settings.AppSettingsSession
import com.ubisam.ubgitea.data.history.GiteaHistoryApi
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
import com.ubisam.ubgitea.domain.history.CommitDetail
import com.ubisam.ubgitea.domain.history.DiffEntry
import com.ubisam.ubgitea.domain.history.DiffRow
import com.ubisam.ubgitea.domain.history.FileChange
import com.ubisam.ubgitea.domain.history.HistoryRepository
@ -75,8 +76,12 @@ sealed interface HistoryUiState {
val file: FileChange,
rows: List<DiffRow>,
val isTruncated: Boolean,
entries: List<DiffEntry> = emptyList(),
) {
val rows = Collections.unmodifiableList(rows.toList())
/** [rows] as shown: numbered, with a header row for each hunk. */
val entries: List<DiffEntry> = Collections.unmodifiableList(entries.toList())
}
data class Error(
val target: HistoryTarget,
@ -152,7 +157,16 @@ class HistoryViewModel(
val current = mutableState.value as? HistoryUiState.CommitDetailContent ?: return
val patch = current.patch as? PatchUiState.Loaded ?: return
val rows = UnifiedDiffParser.parseFile(patch.text, file.path)
mutableState.value = current.withSelectedFile(HistoryUiState.SelectedFileDiff(file, rows, patch.isTruncated))
val entries = UnifiedDiffParser.parseFileEntries(patch.text, file.path)
mutableState.value = current.withSelectedFile(HistoryUiState.SelectedFileDiff(file, rows, patch.isTruncated, entries))
}
/** Moves the open diff to the file [delta] places along the commit's file list, if there is one. */
fun openAdjacentFileDiff(delta: Int) {
val current = mutableState.value as? HistoryUiState.CommitDetailContent ?: return
val selected = current.selectedFile ?: return
val index = current.files.indexOfFirst { it.path == selected.file.path }
current.files.getOrNull(index + delta)?.takeIf { index >= 0 }?.let(::openFileDiff)
}
fun clearFileSelection() {

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

View File

@ -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()

View File

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

View File

@ -1,37 +1,53 @@
package com.ubisam.ubgitea.feature.notifications
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectState
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectType
import com.ubisam.ubgitea.domain.notifications.NotificationSummary
import com.ubisam.ubgitea.ui.SimplePaginationFooter
import com.ubisam.ubgitea.ui.components.ActivityTone
import com.ubisam.ubgitea.ui.components.BrandTopBar
import com.ubisam.ubgitea.ui.components.CenteredLoading
import com.ubisam.ubgitea.ui.components.CenteredMessage
import com.ubisam.ubgitea.ui.components.KindTile
import com.ubisam.ubgitea.ui.components.StatusBadge
import com.ubisam.ubgitea.ui.components.TabTitle
import com.ubisam.ubgitea.ui.formatRelative
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
@Composable
fun NotificationsScreen(
@ -44,116 +60,155 @@ fun NotificationsScreen(
onRetry: () -> Unit,
onBack: () -> Unit,
onAuthenticationExpired: () -> Unit,
accountName: String?,
instanceHost: String,
onViewSettings: () -> Unit,
onLogout: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = onBack)
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
when (state) {
NotificationsUiState.Idle -> Unit
NotificationsUiState.Loading -> {
FilterTabs(unreadOnly = true, onShowUnreadOnly, onShowAll)
CenterMessage { CircularProgressIndicator() }
}
is NotificationsUiState.Content -> {
FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll)
LazyColumn(Modifier.weight(1f)) {
items(state.items, key = NotificationSummary::id) { notification ->
NotificationRow(notification) { onNotificationSelected(notification) }
}
}
PageFooter(state.page, state.page > 1, state.hasNextPage, onPreviousPage, onNextPage)
}
is NotificationsUiState.Empty -> {
FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll)
CenterMessage { Text(stringResource(R.string.notifications_empty)) }
}
is NotificationsUiState.Error -> {
FilterTabs(state.unreadOnly, onShowUnreadOnly, onShowAll)
ErrorMessage(state.type, onRetry, onAuthenticationExpired)
}
Column(modifier.fillMaxSize()) {
BrandTopBar(accountName, instanceHost, onViewSettings, onLogout)
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(bottom = 12.dp)) {
TabTitle(stringResource(R.string.notifications_title))
FilterTabs(state.unreadOnlyOrDefault(), onShowUnreadOnly, onShowAll)
}
}
when (state) {
NotificationsUiState.Idle, NotificationsUiState.Loading -> CenteredLoading(null)
is NotificationsUiState.Content -> NotificationsList(state, onNotificationSelected, onNextPage, onPreviousPage)
is NotificationsUiState.Empty -> CenteredMessage(stringResource(R.string.notifications_empty))
is NotificationsUiState.Error -> {
val expired = state.type == NotificationsError.AUTHENTICATION_EXPIRED
CenteredMessage(
stringResource(state.type.messageResource),
action = stringResource(if (expired) R.string.notifications_sign_in_again else R.string.notifications_retry),
onAction = if (expired) onAuthenticationExpired else onRetry,
)
}
}
}
}
private fun NotificationsUiState.unreadOnlyOrDefault(): Boolean = when (this) {
NotificationsUiState.Idle, NotificationsUiState.Loading -> true
is NotificationsUiState.Content -> unreadOnly
is NotificationsUiState.Empty -> unreadOnly
is NotificationsUiState.Error -> unreadOnly
}
@Composable
private fun FilterTabs(unreadOnly: Boolean, onShowUnreadOnly: () -> Unit, onShowAll: () -> Unit) {
TabRow(selectedTabIndex = if (unreadOnly) 0 else 1) {
Tab(selected = unreadOnly, onClick = onShowUnreadOnly, text = { Text(stringResource(R.string.notifications_filter_unread)) })
Tab(selected = !unreadOnly, onClick = onShowAll, text = { Text(stringResource(R.string.notifications_filter_all)) })
Row(
Modifier.fillMaxWidth().padding(horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(unreadOnly, onShowUnreadOnly, { Text(stringResource(R.string.notifications_filter_unread)) })
FilterChip(!unreadOnly, onShowAll, { Text(stringResource(R.string.notifications_filter_all)) })
}
}
@Composable
private fun NotificationRow(notification: NotificationSummary, onSelected: () -> Unit) {
private fun NotificationsList(
state: NotificationsUiState.Content,
onSelected: (NotificationSummary) -> Unit,
onNextPage: () -> Unit,
onPreviousPage: () -> Unit,
) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
itemsIndexed(state.items, key = { _, item -> item.id }) { index, notification ->
NotificationRow(notification, index == 0, index == state.items.lastIndex) { onSelected(notification) }
}
item(key = "footer") {
SimplePaginationFooter(
previousLabel = stringResource(R.string.notifications_previous_page),
pageLabel = stringResource(R.string.notifications_page_number, state.page),
nextLabel = stringResource(R.string.notifications_next_page),
hasPrevious = state.page > 1,
hasNext = state.hasNextPage,
onPrevious = onPreviousPage,
onNext = onNextPage,
)
}
}
}
@Composable
private fun NotificationRow(notification: NotificationSummary, isFirst: Boolean, isLast: Boolean, onSelected: () -> Unit) {
val navigable = notification.subjectNumber != null &&
(notification.subjectType == NotificationSubjectType.ISSUE || notification.subjectType == NotificationSubjectType.PULL_REQUEST)
Column(
Modifier.fillMaxWidth()
.let { if (navigable) it.clickable(role = Role.Button, onClick = onSelected) else it }
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (notification.isUnread) {
Text(stringResource(R.string.notifications_unread_label), style = MaterialTheme.typography.bodySmall)
val shape = RoundedCornerShape(
topStart = if (isFirst) 20.dp else 0.dp,
topEnd = if (isFirst) 20.dp else 0.dp,
bottomStart = if (isLast) 20.dp else 0.dp,
bottomEnd = if (isLast) 20.dp else 0.dp,
)
val tone = notification.subjectState.tone()
val reference = notification.subjectNumber?.let {
stringResource(R.string.notifications_reference, notification.repositoryName, it)
} ?: notification.repositoryName
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
Column {
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Row(
Modifier.fillMaxWidth()
.let { if (navigable) it.clickable(role = Role.Button, onClick = onSelected) else it }
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (notification.isUnread) {
val description = stringResource(R.string.notifications_unread_dot_description)
Box(
Modifier.padding(top = 6.dp).size(9.dp).clip(CircleShape)
.background(UBGiteaTheme.colors.brand)
.semantics { contentDescription = description },
)
} else {
Spacer(Modifier.size(9.dp).padding(top = 6.dp))
}
KindTile(notification.kindIcon(), tone)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
reference,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
notification.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (notification.isUnread) FontWeight.Bold else FontWeight.Normal,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
StatusBadge(stringResource(notification.subjectState.labelResource), tone)
}
Text(
formatRelative(notification.updatedAtEpochMillis),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
stringResource(R.string.notifications_repository, notification.repositoryOwner, notification.repositoryName),
style = MaterialTheme.typography.bodySmall,
)
Text(stringResource(notification.subjectType.labelResource), style = MaterialTheme.typography.bodySmall)
StatusBadge(notification.subjectState)
}
Text(notification.title, style = MaterialTheme.typography.titleMedium, maxLines = 2)
}
HorizontalDivider()
}
@Composable
private fun ErrorMessage(type: NotificationsError, onRetry: () -> Unit, onAuthenticationExpired: () -> Unit) {
CenterMessage {
Text(stringResource(type.messageResource))
if (type == NotificationsError.AUTHENTICATION_EXPIRED) {
Button(onClick = onAuthenticationExpired) { Text(stringResource(R.string.notifications_sign_in_again)) }
} else {
Button(onClick = onRetry) { Text(stringResource(R.string.notifications_retry)) }
}
}
}
@Composable
private fun PageFooter(page: Int, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) {
SimplePaginationFooter(
previousLabel = stringResource(R.string.notifications_previous_page),
pageLabel = stringResource(R.string.notifications_page_number, page),
nextLabel = stringResource(R.string.notifications_next_page),
hasPrevious = hasPrevious,
hasNext = hasNext,
onPrevious = onPrevious,
onNext = onNext,
)
private fun NotificationSummary.kindIcon(): Int = when (subjectType) {
NotificationSubjectType.ISSUE -> R.drawable.ic_ub_issue
NotificationSubjectType.PULL_REQUEST ->
if (subjectState == NotificationSubjectState.MERGED) R.drawable.ic_ub_merge else R.drawable.ic_ub_pull_request
NotificationSubjectType.COMMIT -> R.drawable.ic_ub_commit
NotificationSubjectType.REPOSITORY -> R.drawable.ic_ub_repo
NotificationSubjectType.OTHER -> R.drawable.ic_ub_more
}
@Composable
private fun CenterMessage(content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
content = content,
)
}
private val NotificationSubjectType.labelResource: Int
get() = when (this) {
NotificationSubjectType.ISSUE -> R.string.notifications_subject_issue
NotificationSubjectType.PULL_REQUEST -> R.string.notifications_subject_pull_request
NotificationSubjectType.COMMIT -> R.string.notifications_subject_commit
NotificationSubjectType.REPOSITORY -> R.string.notifications_subject_repository
NotificationSubjectType.OTHER -> R.string.notifications_subject_other
}
private val NotificationSubjectState.labelResource: Int
get() = when (this) {
NotificationSubjectState.OPEN -> R.string.notifications_state_open
@ -162,28 +217,11 @@ private val NotificationSubjectState.labelResource: Int
NotificationSubjectState.UNKNOWN -> R.string.notifications_state_unknown
}
// Same compact colored-Surface badge WorkItemsScreen's issue/PR rows use (see that file's own
// StatusBadge doc comment for the full rationale) -- kept as its own copy rather than a shared
// component since these are two different feature packages and the two state enums
// (NotificationSubjectState vs. IssueState/PullRequestState) don't share a type to unify over.
// UNKNOWN -- a real Gitea subject state this client doesn't recognize, not a state to visually
// emphasize -- gets the same neutral surfaceVariant treatment VisibilityBadge uses for "private"
// rather than either affirmative-state color.
@Composable
private fun StatusBadge(state: NotificationSubjectState) {
val (container, content) = when (state) {
NotificationSubjectState.OPEN -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
NotificationSubjectState.CLOSED -> MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
NotificationSubjectState.MERGED -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer
NotificationSubjectState.UNKNOWN -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(color = container, contentColor = content, shape = RoundedCornerShape(4.dp)) {
Text(
stringResource(state.labelResource),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
private fun NotificationSubjectState.tone(): ActivityTone = when (this) {
NotificationSubjectState.OPEN -> ActivityTone.SUCCESS
NotificationSubjectState.CLOSED -> ActivityTone.DANGER
NotificationSubjectState.MERGED -> ActivityTone.MERGED
NotificationSubjectState.UNKNOWN -> ActivityTone.NEUTRAL
}
private val NotificationsError.messageResource: Int

View File

@ -43,6 +43,21 @@ class NotificationsViewModel(private val repository: NotificationsRepository) :
val state: StateFlow<NotificationsUiState> = mutableState.asStateFlow()
private var operation: Job? = null
// For the tab's badge. Null until known; a failed refresh keeps the last known count rather
// than blanking the badge.
private val mutableUnreadCount = MutableStateFlow<Int?>(null)
val unreadCount: StateFlow<Int?> = mutableUnreadCount.asStateFlow()
fun refreshUnreadCount() {
viewModelScope.launch {
try {
repository.countUnread()?.let { mutableUnreadCount.value = it }
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
}
}
}
fun open() = loadPage(page = 1, unreadOnly = true)
fun showUnreadOnly() = loadPage(page = 1, unreadOnly = true)
@ -76,6 +91,11 @@ class NotificationsViewModel(private val repository: NotificationsRepository) :
mutableState.value = NotificationsUiState.Idle
}
/** Forgets the count along with the session it belonged to. */
fun clearUnreadCount() {
mutableUnreadCount.value = null
}
private fun loadPage(page: Int, unreadOnly: Boolean) {
operation?.cancel()
mutableState.value = NotificationsUiState.Loading

View File

@ -1,62 +1,80 @@
package com.ubisam.ubgitea.feature.repositories
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.background
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.activity.compose.BackHandler
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
import com.ubisam.ubgitea.ui.SimplePaginationFooter
import com.ubisam.ubgitea.ui.components.UBGiteaTopBar
import com.ubisam.ubgitea.ui.components.ActivityTone
import com.ubisam.ubgitea.ui.components.BrandTopBar
import com.ubisam.ubgitea.ui.components.CautionBanner
import com.ubisam.ubgitea.ui.components.CenteredLoading
import com.ubisam.ubgitea.ui.components.CenteredMessage
import com.ubisam.ubgitea.ui.components.MonoChip
import com.ubisam.ubgitea.ui.components.ScreenTopBar
import com.ubisam.ubgitea.ui.components.SectionTitle
import com.ubisam.ubgitea.ui.components.StatusBadge
import com.ubisam.ubgitea.ui.components.TabTitle
import com.ubisam.ubgitea.ui.components.UBIcon
import com.ubisam.ubgitea.ui.formatInstant
import com.ubisam.ubgitea.ui.formatRelative
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
@Composable
fun RepositoryListScreen(
state: RepositoryListUiState,
instanceHost: String,
onSignIn: () -> Unit,
onCancelSignIn: () -> Unit,
onRetry: () -> Unit,
@ -74,115 +92,101 @@ fun RepositoryListScreen(
onShowFavorites: () -> Unit,
onViewWorkItems: (RepositoryRowUiModel) -> Unit,
onSelectOrganization: (String?) -> Unit,
onViewNotifications: () -> Unit,
onViewSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = { UBGiteaTopBar() },
) { padding ->
Column(modifier.fillMaxSize()) {
when (state) {
RepositoryListUiState.SignedOut -> MessageState(
RepositoryListUiState.SignedOut -> SignedOutState(
stringResource(R.string.repositories_sign_in_description),
stringResource(R.string.repositories_sign_in),
onSignIn,
Modifier.padding(padding),
)
RepositoryListUiState.WaitingForBrowser -> MessageState(
RepositoryListUiState.WaitingForBrowser -> SignedOutState(
stringResource(R.string.repositories_waiting_browser),
stringResource(R.string.repositories_cancel_login),
onCancelSignIn,
Modifier.padding(padding),
)
RepositoryListUiState.Loading -> LoadingState(
stringResource(R.string.repositories_loading),
Modifier.padding(padding),
)
is RepositoryListUiState.Empty -> Column(
Modifier.padding(padding).fillMaxSize()
.modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites),
) {
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
RepositoryListUiState.Loading -> {
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
CenteredLoading(stringResource(R.string.repositories_loading))
}
is RepositoryListUiState.Empty -> {
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(bottom = 12.dp)) {
TabTitle(stringResource(R.string.repositories_list_title))
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
}
}
// FAVORITES-mode empty doesn't mean the account has nothing to show -- it means
// nothing is starred *yet*, while 전체 저장소 (the other tab, right above this
// message) is very likely non-empty. The only sensible primary action here is
// "go look at 전체 저장소 and star something", never 로그아웃 -- offering logout as
// if this were a dead end was a copy-paste leftover from the ALL-mode branch below,
// where the account genuinely has zero repositories and logout is the only thing
// left to do.
// "go look at 전체 저장소 and star something", never 로그아웃.
if (state.mode == RepositoryListMode.FAVORITES) {
MessageState(
CenteredMessage(
stringResource(R.string.repositories_favorites_empty),
stringResource(R.string.repositories_favorites_empty_action),
onShowAllRepositories,
Modifier.weight(1f),
action = stringResource(R.string.repositories_favorites_empty_action),
onAction = onShowAllRepositories,
)
} else {
MessageState(
CenteredMessage(
stringResource(R.string.repositories_empty_account, state.accountName),
stringResource(R.string.repositories_logout),
onLogout,
Modifier.weight(1f),
action = stringResource(R.string.repositories_logout),
onAction = onLogout,
)
}
}
is RepositoryListUiState.Error -> {
BrandTopBar(state.accountNameOrNull(), instanceHost, onViewSettings, onLogout)
val needsLogin = state.type == RepositoryListError.AUTHENTICATION_EXPIRED ||
state.type == RepositoryListError.INVALID_CALLBACK
val baseMessage = state.accountName?.let {
stringResource(R.string.repositories_error_account, it, stringResource(state.type.messageResource))
} ?: stringResource(state.type.messageResource)
MessageState(
CenteredMessage(
state.debugDetail?.let { "$baseMessage\n\n[debug] $it" } ?: baseMessage,
stringResource(if (needsLogin) R.string.repositories_sign_in else R.string.repositories_retry),
if (needsLogin) onSignIn else onRetry,
Modifier.padding(padding),
action = stringResource(if (needsLogin) R.string.repositories_sign_in else R.string.repositories_retry),
onAction = if (needsLogin) onSignIn else onRetry,
secondaryAction = if (state.canLogout) stringResource(R.string.repositories_logout) else null,
onSecondaryAction = onLogout,
)
}
is RepositoryListUiState.Content -> RepositoryContent(
state,
onLogout,
onRepositorySelected,
onNextPage,
onPreviousPage,
onRefreshRepositories,
onRetryLastPushedBranch,
onShowAllRepositories,
onShowFavorites,
onSelectOrganization,
onViewNotifications,
onViewSettings,
onToggleFavorite,
Modifier.padding(padding),
)
is RepositoryListUiState.Content -> {
BrandTopBar(state.accountName, instanceHost, onViewSettings, onLogout)
RepositoryContent(
state,
onRepositorySelected,
onNextPage,
onPreviousPage,
onRefreshRepositories,
onRetryLastPushedBranch,
onShowAllRepositories,
onShowFavorites,
onSelectOrganization,
onToggleFavorite,
)
}
is RepositoryListUiState.ReferencesLoading -> {
BackHandler(onBack = onBackToRepositories)
LoadingState(
stringResource(R.string.repositories_references_loading),
Modifier.padding(padding),
)
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
CenteredLoading(stringResource(R.string.repositories_references_loading))
}
is RepositoryListUiState.ReferencesContent -> {
BackHandler(onBack = onBackToRepositories)
ReferencesContent(
state,
onReferenceSelected,
onReferencesRefresh,
onLogout,
onViewWorkItems,
Modifier.padding(padding),
)
ReferencesContent(state, onReferenceSelected, onReferencesRefresh, onViewWorkItems, onBackToRepositories)
}
is RepositoryListUiState.ReferencesError -> {
BackHandler(onBack = onBackToRepositories)
MessageState(
ScreenTopBar(title = state.repository.name, onBack = onBackToRepositories, subtitle = state.repository.owner)
CenteredMessage(
stringResource(state.type.messageResource),
stringResource(R.string.repositories_retry),
onRetry,
Modifier.padding(padding),
action = stringResource(R.string.repositories_retry),
onAction = onRetry,
secondaryAction = stringResource(R.string.repositories_back),
onSecondaryAction = onBackToRepositories,
)
@ -192,36 +196,25 @@ fun RepositoryListScreen(
}
@Composable
private fun LoadingState(message: String, modifier: Modifier = Modifier) {
private fun SignedOutState(message: String, action: String, onAction: () -> Unit) {
Column(
modifier.fillMaxSize(),
Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
Text(message, modifier = Modifier.padding(top = 16.dp))
}
}
@Composable
private fun MessageState(
message: String,
action: String,
onAction: () -> Unit,
modifier: Modifier = Modifier,
secondaryAction: String? = null,
onSecondaryAction: () -> Unit = {},
) {
Column(
modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(message, style = MaterialTheme.typography.bodyLarge)
Image(
painter = painterResource(R.drawable.logo_ubisam),
contentDescription = stringResource(R.string.shell_logo_description),
modifier = Modifier.height(36.dp),
)
Text(
message,
// Korean breaks between any two characters by default; this keeps words whole.
style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Heading),
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 20.dp),
)
Button(onClick = onAction, modifier = Modifier.padding(top = 20.dp)) { Text(action) }
secondaryAction?.let {
OutlinedButton(onClick = onSecondaryAction, modifier = Modifier.padding(top = 8.dp)) { Text(it) }
}
}
}
@ -229,7 +222,6 @@ private fun MessageState(
@Composable
private fun RepositoryContent(
state: RepositoryListUiState.Content,
onLogout: () -> Unit,
onRepositorySelected: (RepositoryRowUiModel) -> Unit,
onNextPage: () -> Unit,
onPreviousPage: () -> Unit,
@ -238,137 +230,58 @@ private fun RepositoryContent(
onShowAllRepositories: () -> Unit,
onShowFavorites: () -> Unit,
onSelectOrganization: (String?) -> Unit,
onViewNotifications: () -> Unit,
onViewSettings: () -> Unit,
onToggleFavorite: (RepositoryRowUiModel) -> Unit,
modifier: Modifier = Modifier,
) {
PullToRefreshBox(
isRefreshing = false,
onRefresh = onRefreshRepositories,
modifier = modifier.fillMaxSize(),
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
modifier = Modifier.fillMaxWidth(),
) {
LazyColumn(
modifier = Modifier.fillMaxSize().modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites),
) {
item {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(stringResource(R.string.repositories_title), style = MaterialTheme.typography.headlineSmall)
Text(stringResource(R.string.repositories_account, state.accountName))
}
Spacer(Modifier.weight(1f))
OverflowMenuButton(onViewSettings, onViewNotifications, onLogout)
Column(Modifier.padding(bottom = 12.dp)) {
TabTitle(stringResource(R.string.repositories_list_title))
ModeTabs(state.mode, onShowAllRepositories, onShowFavorites)
if (state.mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()) {
OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization)
}
}
state.cachedAtEpochMillis?.let { cachedAt -> item { OfflineCacheBanner(cachedAt) } }
item { ModeTabs(state.mode, onShowAllRepositories, onShowFavorites) }
if (state.mode == RepositoryListMode.ALL && state.organizations.isNotEmpty()) {
item { OrganizationFilterRow(state.organization, state.organizations, onSelectOrganization) }
}
items(state.repositories, key = { repositoryLazyListKey(it.coordinates) }) { repository ->
Column(
Modifier
.fillMaxWidth()
.clickable(role = Role.Button) { onRepositorySelected(repository) }
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text("${repository.owner}/${repository.name}", style = MaterialTheme.typography.titleMedium)
VisibilityBadge(repository.isPrivate, Modifier.padding(top = 4.dp, bottom = 2.dp))
repository.description?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
Text(
repository.defaultBranch?.let {
stringResource(R.string.repositories_default_branch, it)
} ?: stringResource(R.string.repositories_no_default_branch),
style = MaterialTheme.typography.bodySmall,
)
Text(
stringResource(R.string.repositories_last_commit_at, formatInstant(repository.updatedAtEpochMillis)),
style = MaterialTheme.typography.bodySmall,
)
LastPushedBranchRow(repository, onRetryLastPushedBranch)
Row(
Modifier.fillMaxWidth().padding(top = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (repository.language != null) {
Text(repository.language, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
Spacer(Modifier)
}
FavoriteStar(state.mode, repository.favoriteAction, repository.starsCount) { onToggleFavorite(repository) }
}
}
HorizontalDivider()
}
item {
SimplePaginationFooter(
previousLabel = stringResource(R.string.repositories_previous_page),
pageLabel = stringResource(R.string.repositories_page_number, state.page),
nextLabel = stringResource(R.string.repositories_next_page),
hasPrevious = state.page > 1,
hasNext = state.hasNextPage,
onPrevious = onPreviousPage,
onNext = onNextPage,
)
}
}
state.cachedAtEpochMillis?.let { cachedAt ->
CautionBanner(stringResource(R.string.repositories_offline_cached_banner, formatInstant(cachedAt)))
}
PullToRefreshBox(isRefreshing = false, onRefresh = onRefreshRepositories, modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize().modeSwipeGesture(state.mode, onShowAllRepositories, onShowFavorites),
contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp),
) {
itemsIndexed(state.repositories, key = { _, repository -> repositoryLazyListKey(repository.coordinates) }) { index, repository ->
RepositoryRow(
repository,
isFirst = index == 0,
isLast = index == state.repositories.lastIndex,
mode = state.mode,
onSelected = { onRepositorySelected(repository) },
onRetryLastPushedBranch = onRetryLastPushedBranch,
onToggleFavorite = { onToggleFavorite(repository) },
)
}
item(key = "footer") {
SimplePaginationFooter(
previousLabel = stringResource(R.string.repositories_previous_page),
pageLabel = stringResource(R.string.repositories_page_number, state.page),
nextLabel = stringResource(R.string.repositories_next_page),
hasPrevious = state.page > 1,
hasNext = state.hasNextPage,
onPrevious = onPreviousPage,
onNext = onNextPage,
)
}
}
}
}
internal fun repositoryLazyListKey(coordinates: RepositoryCoordinates): String =
"${coordinates.owner}/${coordinates.name}"
// Shown only when this Content came from the on-disk offline cache (RepositoryListViewModel sets
// cachedAtEpochMillis exactly then, never on a live response) -- a full-width banner rather than a
// small badge, since "you might be looking at stale data" is a stronger, page-level statement than
// one repository's visibility. Uses the theme's error-adjacent tertiary-container tone (not the
// affirmative primary green VisibilityBadge/StatusBadge use elsewhere) to read as a caution, not a
// normal state, per product.md's "never obscure which Gitea instance and account are active."
@Composable
private fun OfflineCacheBanner(cachedAtEpochMillis: Long) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
Text(
stringResource(R.string.repositories_offline_cached_banner, formatInstant(cachedAtEpochMillis)),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
// A compact colored badge instead of a full-width plain-text line (which previously had no
// explicit style at all, so it rendered at LocalTextStyle's default size -- larger and more
// visually prominent than the metadata lines around it, an unintentional inconsistency). Private
// gets a neutral surface tint (matches Gitea web's own muted treatment for private repos); public
// gets the theme's primary-container green, since a publicly-visible repository is the more
// "affirmative" state worth a touch of accent color.
@Composable
private fun VisibilityBadge(isPrivate: Boolean, modifier: Modifier = Modifier) {
val label = stringResource(if (isPrivate) R.string.repositories_private else R.string.repositories_public)
val containerColor = if (isPrivate) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primaryContainer
val contentColor = if (isPrivate) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onPrimaryContainer
Surface(
color = containerColor,
contentColor = contentColor,
shape = RoundedCornerShape(4.dp),
modifier = modifier,
) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
// detectHorizontalDragGestures only claims a gesture once its motion resolves as predominantly
// horizontal, so this coexists with LazyColumn's own vertical scroll rather than fighting it.
private fun Modifier.modeSwipeGesture(
@ -387,7 +300,7 @@ private fun Modifier.modeSwipeGesture(
// Content follows the finger, same convention as HorizontalPager elsewhere in this
// app: dragging left (negative totalDragX) brings the next tab in from the right
// (ALL -> FAVORITES, since FAVORITES is the right-hand tab), dragging right brings the
// previous tab in from the left (FAVORITES -> ALL). This was previously inverted.
// previous tab in from the left (FAVORITES -> ALL).
when {
totalDragX < -SwipeThresholdPx && mode == RepositoryListMode.ALL -> onShowFavorites()
totalDragX > SwipeThresholdPx && mode == RepositoryListMode.FAVORITES -> onShowAllRepositories()
@ -405,7 +318,7 @@ private fun OrganizationFilterRow(
onSelect: (String?) -> Unit,
) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp),
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(selected == null, { onSelect(null) }, { Text(stringResource(R.string.repositories_organization_all)) })
@ -419,35 +332,6 @@ private fun OrganizationFilterRow(
}
}
// A single overflow ("hamburger") menu replaces what used to be three separate always-visible
// buttons (설정/알림/로그아웃) on the repository list's top bar — those three buttons crowded the
// header next to the app title. This app has no icon library (see CLAUDE.md's established
// convention), so the trigger is the plain Unicode "☰" glyph, matching how ★/☆ are already used
// elsewhere for favorites rather than a drawable icon.
@Composable
private fun OverflowMenuButton(onViewSettings: () -> Unit, onViewNotifications: () -> Unit, onLogout: () -> Unit) {
var expanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { expanded = true }) {
Text("☰", style = MaterialTheme.typography.titleLarge)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.repositories_settings)) },
onClick = { expanded = false; onViewSettings() },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.repositories_notifications)) },
onClick = { expanded = false; onViewNotifications() },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.repositories_logout)) },
onClick = { expanded = false; onLogout() },
)
}
}
}
@Composable
private fun ModeTabs(mode: RepositoryListMode, onShowAllRepositories: () -> Unit, onShowFavorites: () -> Unit) {
TabRow(selectedTabIndex = if (mode == RepositoryListMode.FAVORITES) 1 else 0) {
@ -464,33 +348,101 @@ private fun ModeTabs(mode: RepositoryListMode, onShowAllRepositories: () -> Unit
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun LastPushedBranchRow(
private fun RepositoryRow(
repository: RepositoryRowUiModel,
onRetry: (RepositoryCoordinates) -> Unit,
isFirst: Boolean,
isLast: Boolean,
mode: RepositoryListMode,
onSelected: () -> Unit,
onRetryLastPushedBranch: (RepositoryCoordinates) -> Unit,
onToggleFavorite: () -> Unit,
) {
when (val branch = repository.lastPushedBranch) {
val shape = RoundedCornerShape(
topStart = if (isFirst) 20.dp else 0.dp,
topEnd = if (isFirst) 20.dp else 0.dp,
bottomStart = if (isLast) 20.dp else 0.dp,
bottomEnd = if (isLast) 20.dp else 0.dp,
)
val ownerColor = MaterialTheme.colorScheme.onSurfaceVariant
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
Column {
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Row(
Modifier.fillMaxWidth()
.clickable(role = Role.Button, onClick = onSelected)
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = ownerColor)) { append(repository.owner); append(" / ") }
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(repository.name) }
},
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
StatusBadge(
stringResource(if (repository.isPrivate) R.string.repositories_private else R.string.repositories_public),
if (repository.isPrivate) ActivityTone.NEUTRAL else ActivityTone.BRAND,
)
}
repository.description?.takeIf(String::isNotBlank)?.let {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (repository.language != null) {
Text(repository.language, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
LastPushedBranchChip(repository.lastPushedBranch) { onRetryLastPushedBranch(repository.coordinates) }
Text(
formatRelative(repository.updatedAtEpochMillis),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
FavoriteStar(mode, repository.favoriteAction, repository.starsCount, onToggleFavorite)
}
}
}
}
@Composable
private fun LastPushedBranchChip(state: LastPushedBranchUiState, onRetry: () -> Unit) {
when (state) {
LastPushedBranchUiState.Loading -> Text(
stringResource(R.string.repositories_last_pushed_branch_loading),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is LastPushedBranchUiState.Loaded -> Text(
stringResource(R.string.repositories_last_pushed_branch, branch.name),
style = MaterialTheme.typography.bodySmall,
)
is LastPushedBranchUiState.Loaded -> MonoChip(state.name, icon = R.drawable.ic_ub_branch)
LastPushedBranchUiState.Empty -> Text(
stringResource(R.string.repositories_last_pushed_branch_none),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
LastPushedBranchUiState.Failed -> Row(verticalAlignment = Alignment.CenterVertically) {
LastPushedBranchUiState.Failed -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
stringResource(R.string.repositories_last_pushed_branch_failed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(
onClick = { onRetry(repository.coordinates) },
modifier = Modifier.padding(start = 8.dp),
) { Text(stringResource(R.string.repositories_retry)) }
onClick = onRetry,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
) { Text(stringResource(R.string.repositories_retry), style = MaterialTheme.typography.labelSmall) }
}
}
}
@ -498,10 +450,9 @@ private fun LastPushedBranchRow(
// Reuses the same toggleFavorite action for both directions: on the ALL list it stars (favoriteAction
// tracks Idle/InProgress/Starred/Failed for *this session's* interaction only — a repository already
// starred before this session still shows as Idle/outlined here until acted on, a known, accepted
// limitation carried over unchanged from how favoriteAction already worked); on the FAVORITES list,
// every row is inherently already starred (that's why it's showing) and the action un-favorites it —
// removing the row on success rather than toggling this icon's own appearance. Star count itself is
// always the server's real count, read-only, shown either way.
// limitation); on the FAVORITES list, every row is inherently already starred (that's why it's
// showing) and the action un-favorites it. Star count itself is always the server's real count,
// read-only, shown either way.
@Composable
private fun FavoriteStar(mode: RepositoryListMode, action: FavoriteActionUiState, starsCount: Int, onClick: () -> Unit) {
val inProgress = action == FavoriteActionUiState.InProgress
@ -509,15 +460,23 @@ private fun FavoriteStar(mode: RepositoryListMode, action: FavoriteActionUiState
val filled = if (mode == RepositoryListMode.FAVORITES) !inProgress else action == FavoriteActionUiState.Starred
val color = when {
failed -> MaterialTheme.colorScheme.error
filled -> MaterialTheme.colorScheme.primary
filled -> UBGiteaTheme.colors.brand
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
Modifier.clickable(role = Role.Button, enabled = !inProgress) { onClick() }.padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
val isUnfavoriteAction = mode == RepositoryListMode.FAVORITES || filled
val description = stringResource(if (isUnfavoriteAction) R.string.repositories_unfavorite else R.string.repositories_favorite)
Column(
Modifier
.heightIn(min = 48.dp)
.widthIn(min = 48.dp)
.clickable(role = Role.Button, enabled = !inProgress, onClick = onClick)
.semantics { contentDescription = description }
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(if (filled) "★" else "☆", color = color, style = MaterialTheme.typography.titleMedium)
Text(starsCount.toString(), color = color, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(start = 4.dp))
UBIcon(if (filled) R.drawable.ic_ub_star_filled else R.drawable.ic_ub_star, null, tint = color, size = 20.dp)
Text(starsCount.toString(), color = color, style = MaterialTheme.typography.bodySmall)
}
}
@ -527,92 +486,105 @@ private fun ReferencesContent(
state: RepositoryListUiState.ReferencesContent,
onReferenceSelected: (RepositoryRowUiModel, String, ReferenceKind) -> Unit,
onRefresh: () -> Unit,
onLogout: () -> Unit,
onViewWorkItems: (RepositoryRowUiModel) -> Unit,
modifier: Modifier = Modifier,
onBackToRepositories: () -> Unit,
) {
PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
"${state.repository.owner}/${state.repository.name}",
style = MaterialTheme.typography.titleLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(stringResource(R.string.repositories_account, state.accountName))
}
OutlinedButton(onClick = onLogout) { Text(stringResource(R.string.repositories_logout)) }
ScreenTopBar(
title = state.repository.name,
onBack = onBackToRepositories,
subtitle = state.repository.owner,
actions = {
IconButton(onClick = { onViewWorkItems(state.repository) }) {
UBIcon(R.drawable.ic_ub_pull_request, stringResource(R.string.repositories_work_items))
}
}
item {
Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { onViewWorkItems(state.repository) }) {
Text(stringResource(R.string.repositories_work_items))
},
)
PullToRefreshBox(isRefreshing = false, onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
item(key = "branches-title") { SectionTitle(stringResource(R.string.repositories_branches_title)) }
if (state.branches.isEmpty()) {
item(key = "branches-empty") { EmptyReferenceMessage(stringResource(R.string.repositories_branches_empty)) }
} else {
itemsIndexed(state.branches, key = { _, reference -> "branch:${reference.name}" }) { index, reference ->
ReferenceRow(reference, index == 0, index == state.branches.lastIndex) { name, kind ->
onReferenceSelected(state.repository, name, kind)
}
}
}
item(key = "tags-title") { SectionTitle(stringResource(R.string.repositories_tags_title), Modifier.padding(top = 8.dp)) }
if (state.tags.isEmpty()) {
item(key = "tags-empty") { EmptyReferenceMessage(stringResource(R.string.repositories_tags_empty)) }
} else {
itemsIndexed(state.tags, key = { _, reference -> "tag:${reference.name}" }) { index, reference ->
ReferenceRow(reference, index == 0, index == state.tags.lastIndex) { name, kind ->
onReferenceSelected(state.repository, name, kind)
}
}
}
}
item {
Text(
stringResource(R.string.repositories_branches_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
)
}
if (state.branches.isEmpty()) {
item { Text(stringResource(R.string.repositories_branches_empty), Modifier.padding(horizontal = 16.dp)) }
}
items(state.branches, key = { "branch:${it.name}" }) { reference ->
ReferenceRow(reference) { name, kind -> onReferenceSelected(state.repository, name, kind) }
}
item {
Text(
stringResource(R.string.repositories_tags_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
)
}
if (state.tags.isEmpty()) {
item { Text(stringResource(R.string.repositories_tags_empty), Modifier.padding(horizontal = 16.dp)) }
}
items(state.tags, key = { "tag:${it.name}" }) { reference ->
ReferenceRow(reference) { name, kind -> onReferenceSelected(state.repository, name, kind) }
}
}
}
}
@Composable
private fun ReferenceRow(reference: ReferenceRowUiModel, onSelected: (String, ReferenceKind) -> Unit) {
val contentColor = if (reference.isSelected) MaterialTheme.colorScheme.primary else Color.Unspecified
Column(
Modifier
.fillMaxWidth()
.background(if (reference.isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)
.selectable(
selected = reference.isSelected,
role = Role.RadioButton,
onClick = { onSelected(reference.name, reference.kind) },
)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text(reference.name, style = MaterialTheme.typography.titleMedium, color = contentColor)
Text(reference.commitId.take(12), style = MaterialTheme.typography.bodySmall, color = contentColor)
reference.commitAtEpochMillis?.let {
Text(
stringResource(R.string.repositories_commit_at, formatInstant(it)),
style = MaterialTheme.typography.bodySmall,
color = contentColor,
)
private fun EmptyReferenceMessage(message: String) {
Text(
message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
}
@Composable
private fun ReferenceRow(reference: ReferenceRowUiModel, isFirst: Boolean, isLast: Boolean, onSelected: (String, ReferenceKind) -> Unit) {
val shape = RoundedCornerShape(
topStart = if (isFirst) 20.dp else 0.dp,
topEnd = if (isFirst) 20.dp else 0.dp,
bottomStart = if (isLast) 20.dp else 0.dp,
bottomEnd = if (isLast) 20.dp else 0.dp,
)
val containerColor = if (reference.isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface
val contentColor = if (reference.isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
val mutedColor = if (reference.isSelected) contentColor else MaterialTheme.colorScheme.onSurfaceVariant
Surface(color = containerColor, shape = shape, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
Column {
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Row(
Modifier.fillMaxWidth()
.selectable(selected = reference.isSelected, role = Role.RadioButton) { onSelected(reference.name, reference.kind) }
.heightIn(min = 48.dp)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UBIcon(
if (reference.kind == ReferenceKind.BRANCH) R.drawable.ic_ub_branch else R.drawable.ic_ub_tag,
null,
tint = mutedColor,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
reference.name,
style = MaterialTheme.typography.titleMedium,
fontFamily = FontFamily.Monospace,
color = contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (reference.isProtected) StatusBadge(stringResource(R.string.repositories_protected_branch), ActivityTone.WARNING)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MonoChip(reference.commitId.take(12))
reference.commitAtEpochMillis?.let {
Text(formatRelative(it), style = MaterialTheme.typography.bodySmall, color = mutedColor)
}
}
}
}
}
if (reference.isProtected) Text(stringResource(R.string.repositories_protected_branch))
}
HorizontalDivider()
}
private val RepositoryListError.messageResource: Int

View File

@ -100,6 +100,17 @@ sealed interface RepositoryListUiState {
) : RepositoryListUiState
}
/** The signed-in account's name, from whichever state carries one. */
fun RepositoryListUiState.accountNameOrNull(): String? = when (this) {
is RepositoryListUiState.Content -> accountName
is RepositoryListUiState.Empty -> accountName
is RepositoryListUiState.Error -> accountName
is RepositoryListUiState.ReferencesLoading -> accountName
is RepositoryListUiState.ReferencesContent -> accountName
is RepositoryListUiState.ReferencesError -> accountName
RepositoryListUiState.SignedOut, RepositoryListUiState.WaitingForBrowser, RepositoryListUiState.Loading -> null
}
sealed interface LastPushedBranchUiState {
data object Loading : LastPushedBranchUiState
data class Loaded(val name: String) : LastPushedBranchUiState

View File

@ -1,62 +1,141 @@
package com.ubisam.ubgitea.feature.settings
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.core.settings.AppSettingsStore
import com.ubisam.ubgitea.ui.components.Avatar
import com.ubisam.ubgitea.ui.components.ListCard
import com.ubisam.ubgitea.ui.components.ScreenTopBar
import com.ubisam.ubgitea.ui.components.SectionTitle
import com.ubisam.ubgitea.ui.components.UBIcon
@Composable
fun SettingsScreen(
state: SettingsUiState,
onSetReactiveAuthRetryEnabled: (Boolean) -> Unit,
onSetFavoritePushNotificationsEnabled: (Boolean) -> Unit,
accountName: String?,
instanceHost: String,
onSetCodeWordWrapEnabled: (Boolean) -> Unit,
onSetCodeFontSizeSp: (Int) -> Unit,
onLogout: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = onBack)
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
Text(stringResource(R.string.settings_title), style = MaterialTheme.typography.headlineSmall)
SettingRow(
title = stringResource(R.string.settings_reactive_auth_retry_title),
description = stringResource(R.string.settings_reactive_auth_retry_description),
checked = state.reactiveAuthRetryEnabled,
onCheckedChange = onSetReactiveAuthRetryEnabled,
)
HorizontalDivider()
SettingRow(
title = stringResource(R.string.settings_favorite_push_notifications_title),
description = stringResource(R.string.settings_favorite_push_notifications_description),
checked = state.favoritePushNotificationsEnabled,
onCheckedChange = onSetFavoritePushNotificationsEnabled,
)
HorizontalDivider()
Column(modifier.fillMaxSize()) {
ScreenTopBar(title = stringResource(R.string.settings_title), onBack = onBack)
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SectionTitle(stringResource(R.string.settings_section_account))
ListCard(Modifier.padding(horizontal = 20.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Avatar(accountName.orEmpty(), size = 40.dp)
Column(Modifier.weight(1f)) {
Text(
accountName ?: stringResource(R.string.settings_account_unknown),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
instanceHost,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
OutlinedButton(onClick = onLogout, modifier = Modifier.heightIn(min = 48.dp)) {
UBIcon(R.drawable.ic_ub_logout, null, size = 18.dp)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.repositories_logout))
}
}
}
SectionTitle(stringResource(R.string.settings_section_code_view), Modifier.padding(top = 8.dp))
ListCard(Modifier.padding(horizontal = 20.dp)) {
SwitchRow(
title = stringResource(R.string.settings_code_word_wrap_title),
description = stringResource(R.string.settings_code_word_wrap_description),
checked = state.codeWordWrapEnabled,
onCheckedChange = onSetCodeWordWrapEnabled,
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.settings_code_font_size_title), style = MaterialTheme.typography.titleMedium)
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AppSettingsStore.CODE_FONT_SIZES_SP.forEach { size ->
FilterChip(
state.codeFontSizeSp == size,
{ onSetCodeFontSizeSp(size) },
{ Text(stringResource(R.string.settings_code_font_size_value, size)) },
)
}
}
}
}
SectionTitle(stringResource(R.string.settings_section_actions), Modifier.padding(top = 8.dp))
ListCard(Modifier.padding(horizontal = 20.dp)) {
SwitchRow(
title = stringResource(R.string.settings_reactive_auth_retry_title),
description = stringResource(R.string.settings_reactive_auth_retry_description),
checked = state.reactiveAuthRetryEnabled,
onCheckedChange = onSetReactiveAuthRetryEnabled,
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
SwitchRow(
title = stringResource(R.string.settings_favorite_push_notifications_title),
description = stringResource(R.string.settings_favorite_push_notifications_description),
checked = state.favoritePushNotificationsEnabled,
onCheckedChange = onSetFavoritePushNotificationsEnabled,
)
}
Spacer(Modifier.height(8.dp))
}
}
}
@Composable
private fun SettingRow(
private fun SwitchRow(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
Modifier.fillMaxWidth().padding(vertical = 16.dp),
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f).padding(end = 12.dp)) {

View File

@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.asStateFlow
data class SettingsUiState(
val reactiveAuthRetryEnabled: Boolean,
val favoritePushNotificationsEnabled: Boolean,
val codeWordWrapEnabled: Boolean = true,
val codeFontSizeSp: Int = AppSettingsStore.DEFAULT_CODE_FONT_SIZE_SP,
)
/**
@ -24,6 +26,8 @@ class SettingsViewModel(private val store: AppSettingsStore) : ViewModel() {
SettingsUiState(
reactiveAuthRetryEnabled = store.isReactiveAuthRetryEnabled(),
favoritePushNotificationsEnabled = store.isFavoritePushNotificationsEnabled(),
codeWordWrapEnabled = store.isCodeWordWrapEnabled(),
codeFontSizeSp = store.codeFontSizeSp(),
),
)
val state: StateFlow<SettingsUiState> = mutableState.asStateFlow()
@ -38,6 +42,17 @@ class SettingsViewModel(private val store: AppSettingsStore) : ViewModel() {
mutableState.value = mutableState.value.copy(favoritePushNotificationsEnabled = enabled)
}
fun setCodeWordWrapEnabled(enabled: Boolean) {
store.setCodeWordWrapEnabled(enabled)
mutableState.value = mutableState.value.copy(codeWordWrapEnabled = enabled)
}
fun setCodeFontSizeSp(size: Int) {
if (size !in AppSettingsStore.CODE_FONT_SIZES_SP) return
store.setCodeFontSizeSp(size)
mutableState.value = mutableState.value.copy(codeFontSizeSp = size)
}
class Factory(private val application: Application) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {

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

View File

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

View File

@ -5,7 +5,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@ -15,18 +14,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
@ -39,11 +36,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.domain.workitems.CommentSummary
@ -56,6 +57,18 @@ import com.ubisam.ubgitea.domain.workitems.ReviewSummary
import com.ubisam.ubgitea.domain.workitems.WorkItemAuthor
import com.ubisam.ubgitea.ui.PaginationButtonHeight
import com.ubisam.ubgitea.ui.SimplePaginationFooter
import com.ubisam.ubgitea.ui.components.ActivityTone
import com.ubisam.ubgitea.ui.components.Avatar
import com.ubisam.ubgitea.ui.components.CenteredLoading
import com.ubisam.ubgitea.ui.components.CenteredMessage
import com.ubisam.ubgitea.ui.components.KindTile
import com.ubisam.ubgitea.ui.components.ListCard
import com.ubisam.ubgitea.ui.components.MonoChip
import com.ubisam.ubgitea.ui.components.ScreenTopBar
import com.ubisam.ubgitea.ui.components.SectionTitle
import com.ubisam.ubgitea.ui.components.StatusBadge
import com.ubisam.ubgitea.ui.components.UBIcon
import com.ubisam.ubgitea.ui.formatRelative
@Composable
fun WorkItemsScreen(
@ -74,70 +87,117 @@ fun WorkItemsScreen(
onAuthenticationExpired: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
when (state) {
WorkItemsUiState.Idle -> Unit
is WorkItemsUiState.Loading -> {
BackHandler(onBack = onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
CenterMessage { CircularProgressIndicator() }
}
is WorkItemsUiState.IssueList -> {
BackHandler(onBack = onBack)
ModeTabs(WorkItemsTab.ISSUES, onShowIssues, onShowPullRequests)
LazyColumn(Modifier.weight(1f)) {
items(state.items, key = IssueSummary::number) { issue ->
IssueRow(issue) { onIssueSelected(issue) }
}
Column(modifier.fillMaxSize()) {
when (state) {
WorkItemsUiState.Idle -> Unit
is WorkItemsUiState.Loading -> {
BackHandler(onBack = onBack)
ListHeader(state.target, onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
CenteredLoading(null)
}
is WorkItemsUiState.IssueList -> {
BackHandler(onBack = onBack)
ListHeader(state.target, onBack)
ModeTabs(WorkItemsTab.ISSUES, onShowIssues, onShowPullRequests)
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
itemsIndexed(state.items, key = { _, issue -> issue.number }) { index, issue ->
WorkItemRow(
icon = R.drawable.ic_ub_issue,
tone = issue.state.tone(),
number = issue.number,
title = issue.title,
statusLabel = stringResource(issue.state.labelResource),
author = issue.author.label(),
updatedAtEpochMillis = issue.updatedAtEpochMillis,
commentCount = issue.commentCount,
isFirst = index == 0,
isLast = index == state.items.lastIndex,
onSelected = { onIssueSelected(issue) },
)
}
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
}
is WorkItemsUiState.PullRequestList -> {
BackHandler(onBack = onBack)
ModeTabs(WorkItemsTab.PULL_REQUESTS, onShowIssues, onShowPullRequests)
LazyColumn(Modifier.weight(1f)) {
items(state.items, key = PullRequestSummary::number) { pr ->
PullRequestRow(pr) { onPullRequestSelected(pr) }
}
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
}
is WorkItemsUiState.PullRequestList -> {
BackHandler(onBack = onBack)
ListHeader(state.target, onBack)
ModeTabs(WorkItemsTab.PULL_REQUESTS, onShowIssues, onShowPullRequests)
LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(top = 16.dp, bottom = 8.dp)) {
itemsIndexed(state.items, key = { _, pr -> pr.number }) { index, pr ->
WorkItemRow(
icon = if (pr.state == PullRequestState.MERGED) R.drawable.ic_ub_merge else R.drawable.ic_ub_pull_request,
tone = pr.state.tone(),
number = pr.number,
title = pr.title,
statusLabel = stringResource(pr.state.labelResource),
author = pr.author.label(),
updatedAtEpochMillis = pr.updatedAtEpochMillis,
commentCount = pr.commentCount,
isFirst = index == 0,
isLast = index == state.items.lastIndex,
onSelected = { onPullRequestSelected(pr) },
)
}
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
}
is WorkItemsUiState.Empty -> {
BackHandler(onBack = onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
CenterMessage { Text(stringResource(R.string.workitems_empty)) }
}
is WorkItemsUiState.ListError -> {
BackHandler(onBack = onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
ErrorMessage(state.type, onRetryList, onAuthenticationExpired)
}
is WorkItemsUiState.IssueDetailLoading -> {
BackHandler(onBack = onBackToList)
CenterMessage { CircularProgressIndicator() }
}
is WorkItemsUiState.IssueDetailContent -> {
BackHandler(onBack = onBackToList)
IssueDetailView(state)
}
is WorkItemsUiState.PullRequestDetailLoading -> {
BackHandler(onBack = onBackToList)
CenterMessage { CircularProgressIndicator() }
}
is WorkItemsUiState.PullRequestDetailContent -> {
BackHandler(onBack = onBackToList)
PullRequestDetailView(state)
}
is WorkItemsUiState.DetailError -> {
BackHandler(onBack = onBackToList)
ErrorMessage(state.type, onRetryDetail, onAuthenticationExpired)
}
PageFooter(state.page, state.page > 1, state.hasNextPage, state.totalPages, onPreviousPage, onNextPage, onGoToPage)
}
is WorkItemsUiState.Empty -> {
BackHandler(onBack = onBack)
ListHeader(state.target, onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
CenteredMessage(stringResource(R.string.workitems_empty))
}
is WorkItemsUiState.ListError -> {
BackHandler(onBack = onBack)
ListHeader(state.target, onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests)
ErrorMessage(state.type, onRetryList, onAuthenticationExpired)
}
is WorkItemsUiState.IssueDetailLoading -> {
BackHandler(onBack = onBackToList)
DetailHeader(state.target, state.number, onBackToList)
CenteredLoading(null)
}
is WorkItemsUiState.IssueDetailContent -> {
BackHandler(onBack = onBackToList)
DetailHeader(state.target, state.detail.number, onBackToList)
IssueDetailView(state)
}
is WorkItemsUiState.PullRequestDetailLoading -> {
BackHandler(onBack = onBackToList)
DetailHeader(state.target, state.number, onBackToList)
CenteredLoading(null)
}
is WorkItemsUiState.PullRequestDetailContent -> {
BackHandler(onBack = onBackToList)
DetailHeader(state.target, state.detail.number, onBackToList)
PullRequestDetailView(state)
}
is WorkItemsUiState.DetailError -> {
BackHandler(onBack = onBackToList)
DetailHeader(state.target, state.number, onBackToList)
ErrorMessage(state.type, onRetryDetail, onAuthenticationExpired)
}
}
}
}
@Composable
private fun ListHeader(target: WorkItemsTarget, onBack: () -> Unit) {
ScreenTopBar(title = target.repository.name, onBack = onBack, subtitle = target.repository.owner)
}
@Composable
private fun DetailHeader(target: WorkItemsTarget, number: Long, onBack: () -> Unit) {
ScreenTopBar(
title = stringResource(R.string.workitems_number, number),
onBack = onBack,
subtitle = target.repository.name,
monospaceTitle = true,
)
}
@Composable
private fun ModeTabs(tab: WorkItemsTab, onShowIssues: () -> Unit, onShowPullRequests: () -> Unit) {
TabRow(selectedTabIndex = if (tab == WorkItemsTab.PULL_REQUESTS) 1 else 0) {
@ -155,119 +215,243 @@ private fun ModeTabs(tab: WorkItemsTab, onShowIssues: () -> Unit, onShowPullRequ
}
@Composable
private fun IssueRow(issue: IssueSummary, onSelected: () -> Unit) {
Column(Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatusBadge(issue.state)
Text("#${issue.number}", style = MaterialTheme.typography.bodySmall)
private fun WorkItemRow(
icon: Int,
tone: ActivityTone,
number: Long,
title: String,
statusLabel: String,
author: String,
updatedAtEpochMillis: Long,
commentCount: Int,
isFirst: Boolean,
isLast: Boolean,
onSelected: () -> Unit,
) {
val shape = RoundedCornerShape(
topStart = if (isFirst) 20.dp else 0.dp,
topEnd = if (isFirst) 20.dp else 0.dp,
bottomStart = if (isLast) 20.dp else 0.dp,
bottomEnd = if (isLast) 20.dp else 0.dp,
)
Surface(color = MaterialTheme.colorScheme.surface, shape = shape, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
Column {
if (!isFirst) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Row(
Modifier.fillMaxWidth()
.clickable(role = Role.Button, onClick = onSelected)
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
KindTile(icon, tone)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatusBadge(statusLabel, tone)
Text(
stringResource(R.string.workitems_number, number),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(R.string.work_meta, author, formatRelative(updatedAtEpochMillis)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
val comments = stringResource(R.string.work_comments_description, commentCount)
Row(
Modifier.semantics(mergeDescendants = true) { contentDescription = comments },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
UBIcon(R.drawable.ic_ub_comment, null, size = 14.dp, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
commentCount.toString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
Text(issue.title, style = MaterialTheme.typography.titleMedium, maxLines = 2)
Text(stringResource(R.string.workitems_by_author, issue.author.label(), issue.commentCount), style = MaterialTheme.typography.bodySmall)
}
HorizontalDivider()
}
@Composable
private fun PullRequestRow(pr: PullRequestSummary, onSelected: () -> Unit) {
Column(Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatusBadge(pr.state)
Text("#${pr.number}", style = MaterialTheme.typography.bodySmall)
}
Text(pr.title, style = MaterialTheme.typography.titleMedium, maxLines = 2)
Text(stringResource(R.string.workitems_by_author, pr.author.label(), pr.commentCount), style = MaterialTheme.typography.bodySmall)
}
HorizontalDivider()
}
@Composable
private fun IssueDetailView(state: WorkItemsUiState.IssueDetailContent) {
LazyColumn(Modifier.fillMaxSize()) {
item {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
StatusBadge(state.detail.state, Modifier.padding(bottom = 4.dp))
Text(state.detail.title, style = MaterialTheme.typography.titleLarge)
Text(stringResource(R.string.workitems_author, state.detail.author.label()), style = MaterialTheme.typography.bodySmall)
if (state.detail.labels.isNotEmpty()) {
Text(state.detail.labels.joinToString(", "), style = MaterialTheme.typography.bodySmall)
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
item(key = "header") {
ListCard(Modifier.padding(horizontal = 20.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatusBadge(stringResource(state.detail.state.labelResource), state.detail.state.tone())
Text(
stringResource(R.string.workitems_number, state.detail.number),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(state.detail.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Avatar(state.detail.author.label(), size = 28.dp)
Text(state.detail.author.label(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
Text(
formatRelative(state.detail.updatedAtEpochMillis),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.detail.labels.isNotEmpty()) {
Text(
state.detail.labels.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.detail.assignees.isNotEmpty()) {
Text(
stringResource(R.string.workitems_assignees, state.detail.assignees.joinToString(", ")),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.detail.body.isNotBlank()) {
Text(state.detail.body, style = MaterialTheme.typography.bodyMedium)
}
}
if (state.detail.assignees.isNotEmpty()) {
Text(
stringResource(R.string.workitems_assignees, state.detail.assignees.joinToString(", ")),
style = MaterialTheme.typography.bodySmall,
)
}
Text(state.detail.body, modifier = Modifier.padding(top = 12.dp))
}
}
item { HorizontalDivider() }
items(state.comments, key = CommentSummary::id) { CommentRow(it) }
if (state.comments.isEmpty()) item { CenterMessage { Text(stringResource(R.string.workitems_no_comments)) } }
item(key = "comments-title") { SectionTitle(stringResource(R.string.workitems_comments_title), Modifier.padding(top = 8.dp)) }
if (state.comments.isEmpty()) {
item(key = "comments-empty") { CenteredMessage(stringResource(R.string.workitems_no_comments), Modifier.padding(vertical = 8.dp)) }
} else {
items(state.comments, key = CommentSummary::id) { comment -> CommentCard(comment) }
}
}
}
@Composable
private fun PullRequestDetailView(state: WorkItemsUiState.PullRequestDetailContent) {
LazyColumn(Modifier.fillMaxSize()) {
item {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
StatusBadge(state.detail.state, Modifier.padding(bottom = 4.dp))
Text(state.detail.title, style = MaterialTheme.typography.titleLarge)
Text(stringResource(R.string.workitems_author, state.detail.author.label()), style = MaterialTheme.typography.bodySmall)
Text(stringResource(R.string.workitems_pr_refs, state.detail.baseRef, state.detail.headRef), style = MaterialTheme.typography.bodySmall)
Text(
stringResource(if (state.detail.isMergeable) R.string.workitems_mergeable else R.string.workitems_not_mergeable),
style = MaterialTheme.typography.bodySmall,
)
Text(state.detail.body, modifier = Modifier.padding(top = 12.dp))
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp)) {
item(key = "header") {
ListCard(Modifier.padding(horizontal = 20.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatusBadge(stringResource(state.detail.state.labelResource), state.detail.state.tone())
Text(
stringResource(R.string.workitems_number, state.detail.number),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(state.detail.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Avatar(state.detail.author.label(), size = 28.dp)
Text(state.detail.author.label(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
Text(
formatRelative(state.detail.updatedAtEpochMillis),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
MonoChip(state.detail.headRef, icon = R.drawable.ic_ub_branch)
UBIcon(R.drawable.ic_ub_chevron_right, null, size = 16.dp, tint = MaterialTheme.colorScheme.onSurfaceVariant)
MonoChip(state.detail.baseRef, icon = R.drawable.ic_ub_branch)
}
StatusBadge(
stringResource(if (state.detail.isMergeable) R.string.workitems_mergeable else R.string.workitems_not_mergeable),
if (state.detail.isMergeable) ActivityTone.SUCCESS else ActivityTone.WARNING,
)
if (state.detail.body.isNotBlank()) {
Text(state.detail.body, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
item {
Text(
stringResource(R.string.workitems_reviews_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
item(key = "reviews-title") { SectionTitle(stringResource(R.string.workitems_reviews_title), Modifier.padding(top = 8.dp)) }
if (state.reviews.isEmpty()) {
item(key = "reviews-empty") { CenteredMessage(stringResource(R.string.workitems_no_reviews), Modifier.padding(vertical = 8.dp)) }
} else {
items(state.reviews, key = ReviewSummary::id) { review -> ReviewCard(review) }
}
item(key = "comments-title") { SectionTitle(stringResource(R.string.workitems_comments_title), Modifier.padding(top = 8.dp)) }
if (state.comments.isEmpty()) {
item(key = "comments-empty") { CenteredMessage(stringResource(R.string.workitems_no_comments), Modifier.padding(vertical = 8.dp)) }
} else {
items(state.comments, key = CommentSummary::id) { comment -> CommentCard(comment) }
}
items(state.reviews, key = ReviewSummary::id) { ReviewRow(it) }
if (state.reviews.isEmpty()) item { CenterMessage { Text(stringResource(R.string.workitems_no_reviews)) } }
item { HorizontalDivider() }
items(state.comments, key = CommentSummary::id) { CommentRow(it) }
if (state.comments.isEmpty()) item { CenterMessage { Text(stringResource(R.string.workitems_no_comments)) } }
}
}
@Composable
private fun ReviewRow(review: ReviewSummary) {
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(review.author.label(), style = MaterialTheme.typography.bodySmall)
Text(stringResource(review.state.labelResource), style = MaterialTheme.typography.bodySmall)
private fun ReviewCard(review: ReviewSummary) {
ListCard(Modifier.padding(horizontal = 20.dp, vertical = 6.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Avatar(review.author.label(), size = 28.dp)
Text(
review.author.label(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
review.submittedAtEpochMillis?.let {
Text(formatRelative(it), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
StatusBadge(stringResource(review.state.labelResource), review.state.tone())
if (review.body.isNotBlank()) Text(review.body, style = MaterialTheme.typography.bodyMedium)
}
if (review.body.isNotBlank()) Text(review.body, style = MaterialTheme.typography.bodyMedium)
}
HorizontalDivider()
}
@Composable
private fun CommentRow(comment: CommentSummary) {
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(comment.author.label(), style = MaterialTheme.typography.bodySmall)
Text(comment.body, style = MaterialTheme.typography.bodyMedium)
private fun CommentCard(comment: CommentSummary) {
ListCard(Modifier.padding(horizontal = 20.dp, vertical = 6.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Avatar(comment.author.label(), size = 28.dp)
Text(
comment.author.label(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Text(
formatRelative(comment.createdAtEpochMillis),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (comment.body.isNotBlank()) Text(comment.body, style = MaterialTheme.typography.bodyMedium)
}
}
HorizontalDivider()
}
@Composable
private fun ErrorMessage(type: WorkItemsError, onRetry: () -> Unit, onAuthenticationExpired: () -> Unit) {
CenterMessage {
Text(stringResource(type.messageResource))
if (type == WorkItemsError.AUTHENTICATION_EXPIRED) {
Button(onClick = onAuthenticationExpired) { Text(stringResource(R.string.workitems_sign_in_again)) }
} else {
Button(onClick = onRetry) { Text(stringResource(R.string.workitems_retry)) }
}
}
val expired = type == WorkItemsError.AUTHENTICATION_EXPIRED
CenteredMessage(
stringResource(type.messageResource),
action = stringResource(if (expired) R.string.workitems_sign_in_again else R.string.workitems_retry),
onAction = if (expired) onAuthenticationExpired else onRetry,
)
}
// Issues/PRs are the one list in this app where the server actually reports a total match count
@ -390,16 +574,6 @@ private fun pageWindow(current: Int, totalPages: Int, windowSize: Int = 5): IntR
return start..(start + windowSize - 1)
}
@Composable
private fun CenterMessage(content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
content = content,
)
}
@Composable
private fun WorkItemAuthor.label(): String = when (this) {
is WorkItemAuthor.Known -> displayName?.takeIf(String::isNotBlank) ?: login
@ -412,6 +586,11 @@ private val IssueState.labelResource: Int
IssueState.CLOSED -> R.string.workitems_issue_closed
}
private fun IssueState.tone(): ActivityTone = when (this) {
IssueState.OPEN -> ActivityTone.SUCCESS
IssueState.CLOSED -> ActivityTone.DANGER
}
private val PullRequestState.labelResource: Int
get() = when (this) {
PullRequestState.OPEN -> R.string.workitems_pr_open
@ -419,48 +598,10 @@ private val PullRequestState.labelResource: Int
PullRequestState.MERGED -> R.string.workitems_pr_merged
}
// 열림/닫힘/병합됨 used to render as a plain bodySmall line indistinguishable in weight from the
// "#12" issue number next to it -- easy to miss when scanning a list. This reuses the same compact
// colored-Surface badge treatment RepositoryListScreen's VisibilityBadge already established for
// 공개/비공개, borrowing existing theme roles rather than inventing new colors: OPEN gets the app's
// green primaryContainer (this app's one "affirmative" accent, same one VisibilityBadge uses for a
// public repository), CLOSED gets the baseline Material3 errorContainer (matches how "closed"/
// "removed" states are treated elsewhere per Theme.kt's own doc comment), and MERGED -- a third,
// visually distinct state GitHub/Gitea conventionally colors purple, which this theme has no role
// for -- gets the teal tertiaryContainer instead of introducing an unthemed one-off color.
@Composable
private fun StatusBadge(state: IssueState, modifier: Modifier = Modifier) {
val (container, content) = when (state) {
IssueState.OPEN -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
IssueState.CLOSED -> MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
}
StatusBadge(stringResource(state.labelResource), container, content, modifier)
}
@Composable
private fun StatusBadge(state: PullRequestState, modifier: Modifier = Modifier) {
val (container, content) = when (state) {
PullRequestState.OPEN -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
PullRequestState.CLOSED -> MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
PullRequestState.MERGED -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer
}
StatusBadge(stringResource(state.labelResource), container, content, modifier)
}
@Composable
private fun StatusBadge(label: String, containerColor: Color, contentColor: Color, modifier: Modifier = Modifier) {
Surface(
color = containerColor,
contentColor = contentColor,
shape = RoundedCornerShape(4.dp),
modifier = modifier,
) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
private fun PullRequestState.tone(): ActivityTone = when (this) {
PullRequestState.OPEN -> ActivityTone.SUCCESS
PullRequestState.CLOSED -> ActivityTone.DANGER
PullRequestState.MERGED -> ActivityTone.MERGED
}
private val ReviewState.labelResource: Int
@ -473,6 +614,15 @@ private val ReviewState.labelResource: Int
ReviewState.OTHER -> R.string.workitems_review_other
}
private fun ReviewState.tone(): ActivityTone = when (this) {
ReviewState.APPROVED -> ActivityTone.SUCCESS
ReviewState.REQUEST_CHANGES -> ActivityTone.DANGER
ReviewState.PENDING -> ActivityTone.WARNING
ReviewState.REQUEST_REVIEW -> ActivityTone.WARNING
ReviewState.COMMENT -> ActivityTone.BRAND
ReviewState.OTHER -> ActivityTone.NEUTRAL
}
private val WorkItemsError.messageResource: Int
get() = when (this) {
WorkItemsError.OFFLINE -> R.string.workitems_offline

View File

@ -133,6 +133,11 @@ class WorkItemsViewModel(private val repository: WorkItemsRepository) : ViewMode
loadPullRequestDetail(target, pullRequest.number)
}
/** Opens one issue or pull request directly, as a notification or a search result does. */
fun openItem(target: WorkItemsTarget, number: Long, isPullRequest: Boolean) {
if (isPullRequest) loadPullRequestDetail(target, number) else loadIssueDetail(target, number)
}
fun retryDetail() {
val current = mutableState.value as? WorkItemsUiState.DetailError ?: return
when (current.tab) {

View File

@ -1,6 +1,10 @@
package com.ubisam.ubgitea.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.ubisam.ubgitea.R
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@ -9,6 +13,48 @@ import java.util.Locale
private val instantFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm (zzz)", Locale.KOREAN)
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.KOREAN)
private val headlineDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("M월 d일 EEEE", Locale.KOREAN)
/** Formats an epoch-millis instant in the device's local time zone, with the zone shown explicitly. */
fun formatInstant(epochMillis: Long): String =
ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()).format(instantFormatter)
fun formatHeadlineDate(date: LocalDate): String = date.format(headlineDateFormatter)
/** How long ago something happened, as a coarse bucket. Anything a week or older is a plain date. */
sealed interface RelativeTime {
data object JustNow : RelativeTime
data class Minutes(val count: Int) : RelativeTime
data class Hours(val count: Int) : RelativeTime
data object Yesterday : RelativeTime
data class Days(val count: Int) : RelativeTime
data class OnDate(val text: String) : RelativeTime
}
fun relativeTimeOf(epochMillis: Long, nowEpochMillis: Long, zone: ZoneId = ZoneId.systemDefault()): RelativeTime {
val elapsedMinutes = (nowEpochMillis - epochMillis) / 60_000L
val then = Instant.ofEpochMilli(epochMillis).atZone(zone).toLocalDate()
val today = Instant.ofEpochMilli(nowEpochMillis).atZone(zone).toLocalDate()
val daysApart = today.toEpochDay() - then.toEpochDay()
return when {
elapsedMinutes < 1 -> RelativeTime.JustNow
elapsedMinutes < 60 -> RelativeTime.Minutes(elapsedMinutes.toInt())
daysApart <= 0L -> RelativeTime.Hours((elapsedMinutes / 60).toInt())
daysApart == 1L -> RelativeTime.Yesterday
daysApart < 7L -> RelativeTime.Days(daysApart.toInt())
else -> RelativeTime.OnDate(then.format(dateFormatter))
}
}
@Composable
fun formatRelative(epochMillis: Long, nowEpochMillis: Long = System.currentTimeMillis()): String =
when (val relative = relativeTimeOf(epochMillis, nowEpochMillis)) {
RelativeTime.JustNow -> stringResource(R.string.time_just_now)
is RelativeTime.Minutes -> stringResource(R.string.time_minutes_ago, relative.count)
is RelativeTime.Hours -> stringResource(R.string.time_hours_ago, relative.count)
RelativeTime.Yesterday -> stringResource(R.string.time_yesterday)
is RelativeTime.Days -> stringResource(R.string.time_days_ago, relative.count)
is RelativeTime.OnDate -> relative.text
}

View 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))
}
}

View File

@ -1,110 +1,185 @@
package com.ubisam.ubgitea.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ubisam.ubgitea.ui.theme.UbiSamLogoBlue
import com.ubisam.ubgitea.ui.theme.UbiSamLogoGray
import com.ubisam.ubgitea.R
/**
* App-wide header: the UbiSam wordmark, small, top-left, on every top-level screen. Nested screens
* (settings, notifications, branch detail, ...) keep the app's existing system-back-gesture
* navigation as-is -- this bar is deliberately just a branding element, not a navigation bar, so it
* carries no back button and doesn't need to know about any screen's back stack.
* The header of a top-level tab: the UbiSam wordmark, which Gitea instance and account are active
* (product.md: never obscure either), and the profile menu that holds settings and sign-out.
*/
@Composable
fun UBGiteaTopBar(modifier: Modifier = Modifier, actions: @Composable RowScope.() -> Unit = {}) {
fun BrandTopBar(
accountName: String?,
instanceHost: String,
onViewSettings: () -> Unit,
onLogout: () -> Unit,
modifier: Modifier = Modifier,
actions: @Composable RowScope.() -> Unit = {},
) {
Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
UBGiteaLogo()
Spacer(Modifier.weight(1f))
actions()
Column(Modifier.padding(start = 20.dp, end = 8.dp, top = 8.dp, bottom = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(R.drawable.logo_ubisam),
contentDescription = stringResource(R.string.shell_logo_description),
modifier = Modifier.height(20.dp),
)
Spacer(Modifier.weight(1f))
actions()
ProfileMenu(accountName, onViewSettings, onLogout)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
instanceHost,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (accountName != null) {
Text(
stringResource(R.string.shell_account_separator, accountName),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
}
}
@Composable
fun UBGiteaLogo(modifier: Modifier = Modifier) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Ubi",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = UbiSamLogoGray,
)
Text(
text = "Sam",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = UbiSamLogoBlue,
)
Spacer(Modifier.width(5.dp))
Box(
modifier = Modifier
.size(18.dp)
.clip(RoundedCornerShape(6.dp))
.background(UbiSamLogoBlue),
contentAlignment = Alignment.Center,
) {
SignalMark(color = Color.White, modifier = Modifier.size(11.dp))
private fun ProfileMenu(accountName: String?, onViewSettings: () -> Unit, onLogout: () -> Unit) {
var expanded by remember { mutableStateOf(false) }
val description = stringResource(R.string.shell_profile_menu)
Box {
IconButton(onClick = { expanded = true }) {
Avatar(accountName.orEmpty(), size = 34.dp, contentDescription = description)
}
}
}
// Stand-in for the real logo's signal/RSS mark, drawn rather than imported so the redesign needs
// no new image asset: a dot plus two quarter-arcs, all centered on the bottom-left corner of the
// box, matching the mark in the real UbiSam wordmark. Swap for an actual drawable resource if/when
// the source image file is available.
@Composable
private fun SignalMark(color: Color, modifier: Modifier = Modifier) {
Canvas(modifier = modifier) {
val corner = Offset(0f, size.height)
val stroke = size.minDimension * 0.22f
drawCircle(
color = color,
radius = size.minDimension * 0.14f,
center = Offset(size.minDimension * 0.14f, size.height - size.minDimension * 0.14f),
)
listOf(0.55f, 0.9f).forEach { fraction ->
val radius = size.minDimension * fraction
drawArc(
color = color,
startAngle = -90f,
sweepAngle = 90f,
useCenter = false,
topLeft = Offset(corner.x - radius, corner.y - radius),
size = Size(radius * 2f, radius * 2f),
style = Stroke(width = stroke, cap = StrokeCap.Round),
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.repositories_settings)) },
leadingIcon = { UBIcon(R.drawable.ic_ub_settings, null) },
onClick = { expanded = false; onViewSettings() },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.repositories_logout)) },
leadingIcon = { UBIcon(R.drawable.ic_ub_logout, null) },
onClick = { expanded = false; onLogout() },
)
}
}
}
/**
* The header of every screen below a tab: a visible back button (the system back gesture still
* works, but is no longer the only way out), a title, and where the screen sits.
*/
@Composable
fun ScreenTopBar(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
monospaceTitle: Boolean = false,
actions: @Composable RowScope.() -> Unit = {},
) {
Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxWidth()) {
Column {
Row(
Modifier.heightIn(min = 56.dp).padding(start = 4.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
UBIcon(R.drawable.ic_ub_back, stringResource(R.string.shell_back))
}
Column(Modifier.weight(1f).padding(end = 8.dp)) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
fontFamily = if (monospaceTitle) FontFamily.Monospace else null,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.semantics { heading() },
)
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
actions()
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
/** The large title block directly under [BrandTopBar] on a tab. */
@Composable
fun TabTitle(title: String, modifier: Modifier = Modifier, subtitle: String? = null) {
Column(modifier.padding(horizontal = 20.dp, vertical = 12.dp)) {
Text(
title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.semantics { heading() },
)
if (subtitle != null) {
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
fun SectionTitle(title: String, modifier: Modifier = Modifier, action: @Composable RowScope.() -> Unit = {}) {
Row(modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f).padding(vertical = 10.dp).semantics { heading() },
)
action()
}
}

View File

@ -7,14 +7,27 @@ package com.ubisam.ubgitea.ui.navigation
* after process death, consistent with the app's no-offline-cache posture.
*/
sealed interface Route {
// The four tabs. A tab is always the bottom of the stack; choosing another tab replaces the
// whole stack with that tab rather than pushing onto it.
data object Home : Route
data object Repositories : Route
data object Work : Route
data object Notifications : Route
// A single screen hosting two swipeable tabs — Files (the code browser) and History — over
// one branch/tag/commit ref. Each tab's own internal drill-down (a subfolder, a commit's
// detail) is handled entirely inside that tab's existing ViewModel state machine, not by
// pushing further routes, so this one route covers the whole branch-detail experience.
data class BranchDetail(val owner: String, val name: String, val refKind: String, val ref: String) : Route
data class WorkItems(val owner: String, val name: String) : Route
data object Notifications : Route
// number is set when the screen opens on one issue or pull request rather than on the list.
data class WorkItems(
val owner: String,
val name: String,
val number: Long? = null,
val isPullRequest: Boolean = false,
) : Route
data object Settings : Route
val isTab: Boolean
get() = this is Home || this is Repositories || this is Work || this is Notifications
}

View File

@ -15,12 +15,16 @@ val RouteStackSaver: Saver<List<Route>, Any> = listSaver(
save = { stack: List<Route> -> stack.map { route -> RouteEncoding.encode(route) } },
restore = { encoded: List<String> ->
encoded.mapNotNull(RouteEncoding::decodeOrNull)
.ifEmpty { listOf(Route.Repositories) }
.ifEmpty { listOf(Route.Home) }
},
)
internal object RouteEncoding {
private const val FIELD_SEPARATOR = ''
private const val TYPE_HOME = "home"
private const val TYPE_WORK = "work"
private const val TYPE_WORK_ITEM_ISSUE = "issue"
private const val TYPE_WORK_ITEM_PULL_REQUEST = "pull"
private const val TYPE_REPOSITORIES = "repositories"
private const val TYPE_BRANCH_DETAIL = "branchDetail"
private const val TYPE_WORK_ITEMS = "workItems"
@ -28,14 +32,26 @@ internal object RouteEncoding {
private const val TYPE_SETTINGS = "settings"
fun encode(route: Route): String = when (route) {
Route.Home -> TYPE_HOME
Route.Repositories -> TYPE_REPOSITORIES
Route.Work -> TYPE_WORK
is Route.BranchDetail -> join(TYPE_BRANCH_DETAIL, route.owner, route.name, route.refKind, route.ref)
is Route.WorkItems -> join(TYPE_WORK_ITEMS, route.owner, route.name)
is Route.WorkItems -> if (route.number == null) {
join(TYPE_WORK_ITEMS, route.owner, route.name)
} else {
join(
TYPE_WORK_ITEMS,
route.owner,
route.name,
if (route.isPullRequest) TYPE_WORK_ITEM_PULL_REQUEST else TYPE_WORK_ITEM_ISSUE,
route.number.toString(),
)
}
Route.Notifications -> TYPE_NOTIFICATIONS
Route.Settings -> TYPE_SETTINGS
}
fun decode(encoded: String): Route = decodeOrNull(encoded) ?: Route.Repositories
fun decode(encoded: String): Route = decodeOrNull(encoded) ?: Route.Home
fun decodeOrNull(encoded: String): Route? {
val parts = encoded.split(FIELD_SEPARATOR)
@ -43,9 +59,19 @@ internal object RouteEncoding {
TYPE_BRANCH_DETAIL -> parts.takeIf { it.size == 5 }?.let {
Route.BranchDetail(owner = it[1], name = it[2], refKind = it[3], ref = it[4])
}
TYPE_WORK_ITEMS -> parts.takeIf { it.size == 3 }?.let {
Route.WorkItems(owner = it[1], name = it[2])
TYPE_WORK_ITEMS -> when (parts.size) {
3 -> Route.WorkItems(owner = parts[1], name = parts[2])
5 -> parts[4].toLongOrNull()?.takeIf { it > 0 }?.let { number ->
when (parts[3]) {
TYPE_WORK_ITEM_ISSUE -> Route.WorkItems(parts[1], parts[2], number, isPullRequest = false)
TYPE_WORK_ITEM_PULL_REQUEST -> Route.WorkItems(parts[1], parts[2], number, isPullRequest = true)
else -> null
}
}
else -> null
}
TYPE_HOME -> Route.Home
TYPE_WORK -> Route.Work
TYPE_NOTIFICATIONS -> Route.Notifications
TYPE_SETTINGS -> Route.Settings
TYPE_REPOSITORIES -> Route.Repositories

View 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

View File

@ -1,64 +1,119 @@
package com.ubisam.ubgitea.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
// Sky-blue + white redesign (superseding the earlier "Gitea green" theme -- see git history for
// that palette if it's ever needed again). Primary is a deep-enough sky blue (Tailwind's sky-700
// family) that white text on it clears WCAG's 4.5:1 threshold for normal-size button labels, while
// still reading unmistakably as "sky blue" rather than navy. Background is a near-white with a
// faint blue cast rather than stark #FFFFFF, so white cards/surfaces still have visible separation
// from the page. Every other role here is a hand-built, standard-shaped Material3 tonal palette
// (primary/secondary/tertiary, each with a container + on-* pair, plus neutrals) grown from that
// same blue family, so the whole app reads as one deliberate sky-blue theme.
// Bright sky blue + white (TASK-050, replacing TASK-045's calmer sky-700 palette). The brand color
// is the blue of the UbiSam wordmark itself (BrandSky). White text on that blue only reaches about
// 3:1, so it is used for marks that carry no text -- icons, activity bars, selection indicators --
// while anything with a label on it (buttons, links) uses the deeper SkyPrimaryLight, which clears
// WCAG's 4.5:1. Status colors (open / merged / closed) deliberately stay outside the blue family:
// they follow Git hosting convention, and a viewer has to tell them apart from the brand color and
// from each other at a glance.
val BrandSky = Color(0xFF2F98F9)
// Light theme
val SkyPrimaryLight = Color(0xFF0369A1)
val SkyPrimaryLight = Color(0xFF0A6FD8)
val OnSkyPrimaryLight = Color(0xFFFFFFFF)
val SkyPrimaryContainerLight = Color(0xFFBAE6FD)
val OnSkyPrimaryContainerLight = Color(0xFF082F49)
val SlateSecondaryLight = Color(0xFF4C6B82)
val SkyPrimaryContainerLight = Color(0xFFDCEEFF)
val OnSkyPrimaryContainerLight = Color(0xFF07509E)
val SlateSecondaryLight = Color(0xFF4B5D73)
val OnSlateSecondaryLight = Color(0xFFFFFFFF)
val SlateSecondaryContainerLight = Color(0xFFD3E4F0)
val OnSlateSecondaryContainerLight = Color(0xFF0F2A3A)
val CyanTertiaryLight = Color(0xFF006874)
val OnCyanTertiaryLight = Color(0xFFFFFFFF)
val CyanTertiaryContainerLight = Color(0xFFA6EEF2)
val OnCyanTertiaryContainerLight = Color(0xFF001F24)
val BackgroundLight = Color(0xFFF7FBFF)
val OnBackgroundLight = Color(0xFF10202B)
val SurfaceVariantLight = Color(0xFFDCE8EF)
val OnSurfaceVariantLight = Color(0xFF41505A)
val OutlineLight = Color(0xFF71818C)
val OutlineVariantLight = Color(0xFFC0D0D9)
val InverseSurfaceLight = Color(0xFF1D333D)
val InverseOnSurfaceLight = Color(0xFFECF3F8)
val InversePrimaryLight = Color(0xFF8ED0FA)
val SlateSecondaryContainerLight = Color(0xFFEAF4FF)
val OnSlateSecondaryContainerLight = Color(0xFF0E1B2C)
val PurpleTertiaryLight = Color(0xFF7A3FD0)
val OnPurpleTertiaryLight = Color(0xFFFFFFFF)
val PurpleTertiaryContainerLight = Color(0xFFEEE5FC)
val OnPurpleTertiaryContainerLight = Color(0xFF4A1F8F)
val ErrorLight = Color(0xFFC62535)
val OnErrorLight = Color(0xFFFFFFFF)
val ErrorContainerLight = Color(0xFFFDE4E6)
val OnErrorContainerLight = Color(0xFF8E1320)
val BackgroundLight = Color(0xFFF3F8FE)
val SurfaceLight = Color(0xFFFFFFFF)
val OnBackgroundLight = Color(0xFF0E1B2C)
val SurfaceVariantLight = Color(0xFFEAF4FF)
val OnSurfaceVariantLight = Color(0xFF4B5D73)
val OutlineLight = Color(0xFF7A8CA3)
val OutlineVariantLight = Color(0xFFD9E6F5)
val SurfaceContainerLowestLight = Color(0xFFFFFFFF)
val SurfaceContainerLowLight = Color(0xFFF7FBFF)
val SurfaceContainerLight = Color(0xFFF0F6FD)
val SurfaceContainerHighLight = Color(0xFFEAF2FB)
val SurfaceContainerHighestLight = Color(0xFFE3EDF8)
val InverseSurfaceLight = Color(0xFF1D2D40)
val InverseOnSurfaceLight = Color(0xFFECF3FA)
val InversePrimaryLight = Color(0xFF6BB8FF)
// Dark theme -- tones flip per Material3 convention (lighter primary for legibility on a dark
// surface; the light theme's primary becomes the dark theme's inversePrimary, and vice versa).
val SkyPrimaryDark = Color(0xFF8ED0FA)
val OnSkyPrimaryDark = Color(0xFF00344D)
val SkyPrimaryContainerDark = Color(0xFF024C6E)
val OnSkyPrimaryContainerDark = Color(0xFFBAE6FD)
val SlateSecondaryDark = Color(0xFFB4CADD)
val OnSlateSecondaryDark = Color(0xFF1C3444)
val SlateSecondaryContainerDark = Color(0xFF34495C)
val OnSlateSecondaryContainerDark = Color(0xFFD3E4F0)
val CyanTertiaryDark = Color(0xFF83D2DB)
val OnCyanTertiaryDark = Color(0xFF00363D)
val CyanTertiaryContainerDark = Color(0xFF004F58)
val OnCyanTertiaryContainerDark = Color(0xFFA6EEF2)
val BackgroundDark = Color(0xFF0B141B)
val OnBackgroundDark = Color(0xFFE3EDF3)
val SurfaceVariantDark = Color(0xFF41505A)
val OnSurfaceVariantDark = Color(0xFFC0D0D9)
val OutlineDark = Color(0xFF8B9AA4)
val OutlineVariantDark = Color(0xFF41505A)
val InverseSurfaceDark = Color(0xFFE3EDF3)
val InverseOnSurfaceDark = Color(0xFF1D333D)
val InversePrimaryDark = Color(0xFF0369A1)
// Dark theme
val SkyPrimaryDark = Color(0xFF6BB8FF)
val OnSkyPrimaryDark = Color(0xFF04213F)
val SkyPrimaryContainerDark = Color(0xFF123A63)
val OnSkyPrimaryContainerDark = Color(0xFFBFE0FF)
val SlateSecondaryDark = Color(0xFF9FB2C8)
val OnSlateSecondaryDark = Color(0xFF0B1B2D)
val SlateSecondaryContainerDark = Color(0xFF16293F)
val OnSlateSecondaryContainerDark = Color(0xFFE8F0FA)
val PurpleTertiaryDark = Color(0xFFBC95F7)
val OnPurpleTertiaryDark = Color(0xFF2A1B4A)
val PurpleTertiaryContainerDark = Color(0xFF2A1B4A)
val OnPurpleTertiaryContainerDark = Color(0xFFE2D3FF)
val ErrorDark = Color(0xFFFF8A84)
val OnErrorDark = Color(0xFF3B0A0D)
val ErrorContainerDark = Color(0xFF471A1D)
val OnErrorContainerDark = Color(0xFFFFD0CD)
val BackgroundDark = Color(0xFF08121F)
val SurfaceDark = Color(0xFF101D2E)
val OnBackgroundDark = Color(0xFFE8F0FA)
val SurfaceVariantDark = Color(0xFF16293F)
val OnSurfaceVariantDark = Color(0xFF9FB2C8)
val OutlineDark = Color(0xFF6F849C)
val OutlineVariantDark = Color(0xFF22364E)
val SurfaceContainerLowestDark = Color(0xFF060E18)
val SurfaceContainerLowDark = Color(0xFF0C1827)
val SurfaceContainerDark = Color(0xFF101D2E)
val SurfaceContainerHighDark = Color(0xFF16293F)
val SurfaceContainerHighestDark = Color(0xFF1C3350)
val InverseSurfaceDark = Color(0xFFE8F0FA)
val InverseOnSurfaceDark = Color(0xFF1D2D40)
val InversePrimaryDark = Color(0xFF0A6FD8)
// UbiSam wordmark colors -- fixed brand colors independent of light/dark theme (a logo should look
// the same in both modes, unlike the tonal palette above).
val UbiSamLogoGray = Color(0xFF5B6472)
val UbiSamLogoBlue = Color(0xFF0EA5E9)
/**
* Roles Material3's own scheme has no slot for. `success` is the "open" / "added" color, `warning`
* the caution color (offline data, truncation); "merged" is the scheme's tertiary (purple) and
* "closed" / "removed" its error (red).
*/
@Immutable
data class UBGiteaExtendedColors(
val brand: Color,
val success: Color,
val successContainer: Color,
val onSuccessContainer: Color,
val warning: Color,
val warningContainer: Color,
val onWarningContainer: Color,
)
val LightExtendedColors = UBGiteaExtendedColors(
brand = BrandSky,
success = Color(0xFF1A7F37),
successContainer = Color(0xFFDDF4E4),
onSuccessContainer = Color(0xFF0F5A26),
warning = Color(0xFFB45309),
warningContainer = Color(0xFFFDEBD3),
onWarningContainer = Color(0xFF7A3A06),
)
val DarkExtendedColors = UBGiteaExtendedColors(
brand = BrandSky,
success = Color(0xFF56D07A),
successContainer = Color(0xFF0F3320),
onSuccessContainer = Color(0xFFA6EBBB),
warning = Color(0xFFF5B566),
warningContainer = Color(0xFF40290B),
onWarningContainer = Color(0xFFFFDDB0),
)
val LocalUBGiteaColors = staticCompositionLocalOf { LightExtendedColors }

View File

@ -5,13 +5,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
// Sky-blue Material3 color schemes (see Color.kt for the individual values and how they were
// derived). Every role not set explicitly here -- error/onError/errorContainer/onErrorContainer,
// most notably, kept as Material3's own baseline red -- is left at the corresponding
// lightColorScheme()/darkColorScheme() default rather than reinvented, since nothing about this
// request was about changing what "error" or "removed" looks like, only about the app's own brand
// color.
// See Color.kt for the individual values and why each role has the color it has. Surfaces are
// white cards on a faintly blue page in light mode, so `surface` and `background` differ.
private val LightColorScheme = lightColorScheme(
primary = SkyPrimaryLight,
onPrimary = OnSkyPrimaryLight,
@ -21,18 +19,27 @@ private val LightColorScheme = lightColorScheme(
onSecondary = OnSlateSecondaryLight,
secondaryContainer = SlateSecondaryContainerLight,
onSecondaryContainer = OnSlateSecondaryContainerLight,
tertiary = CyanTertiaryLight,
onTertiary = OnCyanTertiaryLight,
tertiaryContainer = CyanTertiaryContainerLight,
onTertiaryContainer = OnCyanTertiaryContainerLight,
tertiary = PurpleTertiaryLight,
onTertiary = OnPurpleTertiaryLight,
tertiaryContainer = PurpleTertiaryContainerLight,
onTertiaryContainer = OnPurpleTertiaryContainerLight,
error = ErrorLight,
onError = OnErrorLight,
errorContainer = ErrorContainerLight,
onErrorContainer = OnErrorContainerLight,
background = BackgroundLight,
onBackground = OnBackgroundLight,
surface = BackgroundLight,
surface = SurfaceLight,
onSurface = OnBackgroundLight,
surfaceVariant = SurfaceVariantLight,
onSurfaceVariant = OnSurfaceVariantLight,
outline = OutlineLight,
outlineVariant = OutlineVariantLight,
surfaceContainerLowest = SurfaceContainerLowestLight,
surfaceContainerLow = SurfaceContainerLowLight,
surfaceContainer = SurfaceContainerLight,
surfaceContainerHigh = SurfaceContainerHighLight,
surfaceContainerHighest = SurfaceContainerHighestLight,
inverseSurface = InverseSurfaceLight,
inverseOnSurface = InverseOnSurfaceLight,
inversePrimary = InversePrimaryLight,
@ -47,18 +54,27 @@ private val DarkColorScheme = darkColorScheme(
onSecondary = OnSlateSecondaryDark,
secondaryContainer = SlateSecondaryContainerDark,
onSecondaryContainer = OnSlateSecondaryContainerDark,
tertiary = CyanTertiaryDark,
onTertiary = OnCyanTertiaryDark,
tertiaryContainer = CyanTertiaryContainerDark,
onTertiaryContainer = OnCyanTertiaryContainerDark,
tertiary = PurpleTertiaryDark,
onTertiary = OnPurpleTertiaryDark,
tertiaryContainer = PurpleTertiaryContainerDark,
onTertiaryContainer = OnPurpleTertiaryContainerDark,
error = ErrorDark,
onError = OnErrorDark,
errorContainer = ErrorContainerDark,
onErrorContainer = OnErrorContainerDark,
background = BackgroundDark,
onBackground = OnBackgroundDark,
surface = BackgroundDark,
surface = SurfaceDark,
onSurface = OnBackgroundDark,
surfaceVariant = SurfaceVariantDark,
onSurfaceVariant = OnSurfaceVariantDark,
outline = OutlineDark,
outlineVariant = OutlineVariantDark,
surfaceContainerLowest = SurfaceContainerLowestDark,
surfaceContainerLow = SurfaceContainerLowDark,
surfaceContainer = SurfaceContainerDark,
surfaceContainerHigh = SurfaceContainerHighDark,
surfaceContainerHighest = SurfaceContainerHighestDark,
inverseSurface = InverseSurfaceDark,
inverseOnSurface = InverseOnSurfaceDark,
inversePrimary = InversePrimaryDark,
@ -69,8 +85,15 @@ fun UBGiteaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
content = content,
)
CompositionLocalProvider(LocalUBGiteaColors provides if (darkTheme) DarkExtendedColors else LightExtendedColors) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
content = content,
)
}
}
object UBGiteaTheme {
val colors: UBGiteaExtendedColors
@Composable @ReadOnlyComposable get() = LocalUBGiteaColors.current
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -1,4 +1,3 @@
<resources>
<string name="app_name">UBGitea</string>
<string name="home_read_only_placeholder">저장소, 브랜치와 코드를 안전하게 둘러볼 읽기 전용 앱을 준비하고 있습니다.</string>
</resources>

View File

@ -1,4 +1,5 @@
<resources>
<string name="branchdetail_tab_files">파일</string>
<string name="branchdetail_tab_history">커밋</string>
<string name="branchdetail_subtitle">%1$s · %2$s</string>
</resources>

View File

@ -31,4 +31,45 @@
<string name="codebrowser_image_decode_failed">이미지를 표시할 수 없습니다.</string>
<string name="codebrowser_csv_empty">이 CSV 파일은 비어 있습니다.</string>
<string name="codebrowser_pdf_decode_failed">PDF를 표시할 수 없습니다.</string>
<string name="codeviewer_location">%1$s · %2$s</string>
<string name="codeviewer_line_count">%1$d줄</string>
<string name="codeviewer_current_location">현재 위치</string>
<string name="codeviewer_tools">코드 보기 도구</string>
<string name="codeviewer_tool_search">검색</string>
<string name="codeviewer_tool_outline">목차</string>
<string name="codeviewer_tool_wrap">줄바꿈</string>
<string name="codeviewer_tool_font_size">글자 크기</string>
<string name="codeviewer_tool_copy">복사</string>
<string name="codeviewer_tool_side_by_side">나란히</string>
<string name="codeviewer_tool_sync">짝 맞춰 이동</string>
<string name="codeviewer_state_on">켜짐</string>
<string name="codeviewer_state_off">꺼짐</string>
<string name="codeviewer_font_size_option">%1$dsp</string>
<string name="codeviewer_search_hint">파일 안에서 검색</string>
<string name="codeviewer_search_close">검색 닫기</string>
<string name="codeviewer_search_count">%1$d / %2$d</string>
<string name="codeviewer_search_none">결과 없음</string>
<string name="codeviewer_search_previous">이전 결과</string>
<string name="codeviewer_search_next">다음 결과</string>
<string name="codeviewer_outline_title">목차</string>
<string name="codeviewer_outline_close">닫기</string>
<string name="codeviewer_outline_hint">줄 번호</string>
<string name="codeviewer_outline_go">이동</string>
<string name="codeviewer_outline_empty">이 파일에서는 목차를 만들 수 없습니다. 줄 번호로 이동할 수 있습니다.</string>
<string name="codeviewer_outline_line">줄 %1$d</string>
<string name="codeviewer_outline_line_out_of_range">1~%1$d 사이의 줄 번호를 입력하세요.</string>
<string name="codeviewer_symbol_namespace">네임스페이스</string>
<string name="codeviewer_symbol_type">타입</string>
<string name="codeviewer_symbol_method">메서드</string>
<string name="codeviewer_symbol_field">필드</string>
<string name="codeviewer_files_title">파일</string>
<string name="codeviewer_pair_label">짝 파일</string>
<string name="codeviewer_pair_header">선언</string>
<string name="codeviewer_pair_source">구현</string>
<string name="codeviewer_jump_to_header">선언 보기 · %1$s</string>
<string name="codeviewer_jump_to_source">구현 보기 · %1$s</string>
<string name="codeviewer_counterpart_loading">짝 파일을 불러오는 중입니다.</string>
<string name="codeviewer_counterpart_failed">짝 파일을 불러오지 못했습니다.</string>
<string name="codeviewer_counterpart_not_text">짝 파일은 텍스트로 표시할 수 없습니다.</string>
<string name="codeviewer_more">더 보기</string>
</resources>

View File

@ -27,4 +27,21 @@
<string name="history_conflict">저장소가 비어 있거나 비교할 수 없는 상태입니다.</string>
<string name="history_unprocessable">요청을 처리할 수 없습니다.</string>
<string name="history_server_error">Gitea 서버에서 오류가 발생했습니다.</string>
<string name="history_files_changed">변경된 파일 %1$d개</string>
<string name="history_patch_title">전체 변경 내용</string>
<string name="history_diff_file_position">파일 %1$d / %2$d</string>
<string name="history_diff_added_count">+%1$d</string>
<string name="history_diff_removed_count">−%1$d</string>
<string name="history_diff_added_description">추가 %1$d줄</string>
<string name="history_diff_removed_description">삭제 %1$d줄</string>
<string name="history_diff_previous_file">이전 파일</string>
<string name="history_diff_next_file">다음 파일</string>
<string name="history_diff_previous_change">이전 변경</string>
<string name="history_diff_next_change">다음 변경</string>
<string name="history_diff_file_list">파일 목록</string>
<string name="history_diff_tools">변경 내용 도구</string>
<string name="history_diff_line_added">추가된 줄</string>
<string name="history_diff_line_removed">삭제된 줄</string>
<string name="history_diff_hunk">변경 구간</string>
<string name="history_loading">커밋을 불러오는 중입니다.</string>
</resources>

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

View File

@ -3,19 +3,14 @@
<string name="notifications_title">알림</string>
<string name="notifications_filter_unread">읽지 않음</string>
<string name="notifications_filter_all">전체</string>
<string name="notifications_unread_label">[읽지 않음]</string>
<string name="notifications_unread_dot_description">읽지 않음</string>
<string name="notifications_empty">알림이 없습니다.</string>
<string name="notifications_retry">다시 시도</string>
<string name="notifications_sign_in_again">다시 로그인</string>
<string name="notifications_previous_page">이전 페이지</string>
<string name="notifications_next_page">다음 페이지</string>
<string name="notifications_page_number">%1$d페이지</string>
<string name="notifications_repository">%1$s/%2$s</string>
<string name="notifications_subject_issue">이슈</string>
<string name="notifications_subject_pull_request">PR</string>
<string name="notifications_subject_commit">커밋</string>
<string name="notifications_subject_repository">저장소</string>
<string name="notifications_subject_other">기타</string>
<string name="notifications_reference">%1$s #%2$d</string>
<string name="notifications_state_open">열림</string>
<string name="notifications_state_closed">닫힘</string>
<string name="notifications_state_merged">병합됨</string>

View File

@ -18,24 +18,17 @@
<string name="repositories_logout">로그아웃</string>
<string name="repositories_private">비공개</string>
<string name="repositories_public">공개</string>
<string name="repositories_default_branch">기본 브랜치: %1$s</string>
<string name="repositories_no_default_branch">기본 브랜치 없음</string>
<string name="repositories_last_commit_at">마지막 커밋: %1$s</string>
<string name="repositories_account">%1$s 계정</string>
<string name="repositories_references_loading">로딩 중</string>
<string name="repositories_branches_title">브랜치</string>
<string name="repositories_branches_empty">브랜치가 없습니다.</string>
<string name="repositories_tags_title">태그</string>
<string name="repositories_tags_empty">태그가 없습니다.</string>
<string name="repositories_protected_branch">보호된 브랜치</string>
<string name="repositories_commit_at">커밋 시각: %1$s</string>
<string name="repositories_back">저장소 목록</string>
<string name="repositories_refresh">새로고침</string>
<string name="repositories_previous_page">이전 페이지</string>
<string name="repositories_next_page">다음 페이지</string>
<string name="repositories_page_number">%1$d페이지</string>
<string name="repositories_last_pushed_branch_loading">마지막 push 브랜치 확인 중…</string>
<string name="repositories_last_pushed_branch">마지막 push: %1$s</string>
<string name="repositories_last_pushed_branch_none">브랜치가 없습니다.</string>
<string name="repositories_last_pushed_branch_failed">마지막 push 브랜치를 불러오지 못했습니다.</string>
<string name="repositories_tab_all">전체 저장소</string>
@ -48,4 +41,5 @@
<string name="repositories_favorited">즐겨찾기됨</string>
<string name="repositories_work_items">PR/이슈</string>
<string name="repositories_organization_all">전체</string>
<string name="repositories_list_title">저장소</string>
</resources>

View File

@ -4,4 +4,12 @@
<string name="settings_reactive_auth_retry_description">인증이 예기치 않게 만료되면, 재로그인 화면을 띄우기 전에 한 번 더 조용히 갱신을 시도합니다.</string>
<string name="settings_favorite_push_notifications_title">즐겨찾기 저장소 Push 알림</string>
<string name="settings_favorite_push_notifications_description">즐겨찾기한 저장소에 새로운 push가 있으면 알려드립니다.</string>
<string name="settings_section_account">계정</string>
<string name="settings_section_code_view">코드 보기</string>
<string name="settings_section_actions">동작</string>
<string name="settings_account_unknown">알 수 없는 계정</string>
<string name="settings_code_word_wrap_title">자동 줄바꿈</string>
<string name="settings_code_word_wrap_description">코드 보기 화면에서 긴 줄을 자동으로 줄바꿈합니다.</string>
<string name="settings_code_font_size_title">글자 크기</string>
<string name="settings_code_font_size_value">%1$dsp</string>
</resources>

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

View File

@ -10,14 +10,13 @@
<string name="workitems_page_jump_suffix">/ %1$d ]</string>
<string name="workitems_page_jump_go">이동</string>
<string name="workitems_page_jump_out_of_range">1~%1$d 사이의 페이지를 입력하세요.</string>
<string name="workitems_by_author">%1$s · 댓글 %2$d개</string>
<string name="workitems_author">작성자: %1$s</string>
<string name="workitems_author_unavailable">(알 수 없는 사용자)</string>
<string name="workitems_assignees">담당자: %1$s</string>
<string name="workitems_no_comments">댓글이 없습니다.</string>
<string name="workitems_no_reviews">리뷰가 없습니다.</string>
<string name="workitems_reviews_title">리뷰</string>
<string name="workitems_pr_refs">%1$s ← %2$s</string>
<string name="workitems_comments_title">댓글</string>
<string name="workitems_number">#%1$d</string>
<string name="workitems_mergeable">병합 가능</string>
<string name="workitems_not_mergeable">병합 불가</string>
<string name="workitems_issue_open">열림</string>

View File

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

View File

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

View File

@ -1,6 +1,7 @@
package com.ubisam.ubgitea.domain.history
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@ -132,4 +133,87 @@ class UnifiedDiffParserTest {
assertEquals(DiffLine.Removed("bye"), rows[0].left)
assertNull(rows[0].right)
}
// --- parseFileEntries ---
@Test
fun `each hunk gets a header entry in front carrying the text after the second @@`() {
val patch = """
diff --git a/foo.kt b/foo.kt
--- a/foo.kt
+++ b/foo.kt
@@ -10,3 +12,4 @@ fun main() {
unchanged
-old
+new
""".trimIndent()
val entries = UnifiedDiffParser.parseFileEntries(patch, "foo.kt")
assertTrue(entries[0].isHunkHeader)
assertEquals("fun main() {", entries[0].hunkLabel)
assertFalse(entries[1].isHunkHeader)
}
@Test
fun `a hunk header without trailing text after the second @@ has an empty hunkLabel`() {
val patch = """
diff --git a/foo.kt b/foo.kt
--- a/foo.kt
+++ b/foo.kt
@@ -1,1 +1,1 @@
-a
+b
""".trimIndent()
val header = UnifiedDiffParser.parseFileEntries(patch, "foo.kt").first()
assertTrue(header.isHunkHeader)
assertEquals("", header.hunkLabel)
}
@Test
fun `left and right line numbers start from the hunk header's own starting numbers`() {
val patch = """
diff --git a/foo.kt b/foo.kt
--- a/foo.kt
+++ b/foo.kt
@@ -10,3 +12,4 @@
unchanged
-old
+new
+added
""".trimIndent()
val entries = UnifiedDiffParser.parseFileEntries(patch, "foo.kt")
val rows = entries.drop(1)
// "unchanged" is context: present, and numbered, on both sides.
assertEquals(10, rows[0].leftNumber)
assertEquals(12, rows[0].rightNumber)
// "old"/"new" pair up as a change.
assertEquals(11, rows[1].leftNumber)
assertEquals(13, rows[1].rightNumber)
// "added" has no counterpart on the left.
assertNull(rows[2].leftNumber)
assertEquals(14, rows[2].rightNumber)
}
@Test
fun `an addition-only row has a null leftNumber`() {
val patch = """
diff --git a/foo.kt b/foo.kt
--- a/foo.kt
+++ b/foo.kt
@@ -1,1 +1,2 @@
unchanged
+added
""".trimIndent()
val entries = UnifiedDiffParser.parseFileEntries(patch, "foo.kt")
val added = entries.last()
assertNull(added.leftNumber)
assertEquals(2, added.rightNumber)
}
}

View File

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

View File

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

View File

@ -85,7 +85,12 @@ class SyntaxHighlighterTest {
@Test
fun colorsHexAndSuffixedNumbers() {
assertEquals(
listOf("0xFF" to TokenRole.NUMBER, "2.5f" to TokenRole.NUMBER, "10UL" to TokenRole.NUMBER),
listOf(
"a" to TokenRole.METHOD,
"0xFF" to TokenRole.NUMBER,
"2.5f" to TokenRole.NUMBER,
"10UL" to TokenRole.NUMBER,
),
tokens(SyntaxLanguage.CPP, "a(0xFF, 2.5f, 10UL);").single(),
)
}
@ -112,7 +117,7 @@ class SyntaxHighlighterTest {
val result = tokens(SyntaxLanguage.CPP, "int a; /* starts", "ends */ return 1;")
assertEquals(listOf("int" to TokenRole.KEYWORD, "/* starts" to TokenRole.COMMENT), result[0])
assertEquals(
listOf("ends */" to TokenRole.COMMENT, "return" to TokenRole.KEYWORD, "1" to TokenRole.NUMBER),
listOf("ends */" to TokenRole.COMMENT, "return" to TokenRole.CONTROL_KEYWORD, "1" to TokenRole.NUMBER),
result[1],
)
}
@ -173,4 +178,102 @@ class SyntaxHighlighterTest {
assertEquals(listOf("int a;" to TokenRole.COMMENT), result[1])
assertEquals(listOf("int b;" to TokenRole.COMMENT), result[2])
}
// --- non-keyword identifiers (TASK-053 redesign) ---
@Test
fun colorsIdentifierFollowedByParenthesisAsMethod() {
val line = tokens(SyntaxLanguage.CSHARP, "doRetry(attempt);").single()
assertEquals("doRetry" to TokenRole.METHOD, line.first())
}
@Test
fun colorsIdentifierAfterNewAsTypeNotMethod() {
val line = tokens(SyntaxLanguage.CSHARP, "var p = new RetryPolicy(3);").single()
assertEquals(
listOf(
"var" to TokenRole.KEYWORD,
"new" to TokenRole.KEYWORD,
"RetryPolicy" to TokenRole.TYPE,
"3" to TokenRole.NUMBER,
),
line,
)
}
@Test
fun colorsConstructorCallAfterNewInCpp() {
val line = tokens(SyntaxLanguage.CPP, "auto p = new RetryPolicy(3);").single()
assertEquals(
listOf(
"auto" to TokenRole.KEYWORD,
"new" to TokenRole.KEYWORD,
"RetryPolicy" to TokenRole.TYPE,
"3" to TokenRole.NUMBER,
),
line,
)
}
@Test
fun colorsCapitalizedIdentifierAsType() {
val line = tokens(SyntaxLanguage.CPP, "RetryPolicy policy;").single()
assertEquals(listOf("RetryPolicy" to TokenRole.TYPE), line)
}
@Test
fun excludesCapitalizedMemberAccessFromType() {
val line = tokens(SyntaxLanguage.CSHARP, "var x = obj.Value;").single()
assertTrue(line.none { it.second == TokenRole.TYPE })
}
@Test
fun doesNotColorAllCapsIdentifierAsType() {
val line = tokens(SyntaxLanguage.CPP, "int MAX_RETRY;").single()
assertEquals(listOf("int" to TokenRole.KEYWORD), line)
}
@Test
fun colorsCTypeSuffixAsType() {
val line = tokens(SyntaxLanguage.CPP, "size_t count;").single()
assertEquals(listOf("size_t" to TokenRole.TYPE), line)
}
@Test
fun doesNotColorMemberPrefixedIdentifier() {
val line = tokens(SyntaxLanguage.CPP, "int m_count;").single()
assertEquals(listOf("int" to TokenRole.KEYWORD), line)
}
@Test
fun doesNotColorUnderscorePrefixedIdentifier() {
val line = tokens(SyntaxLanguage.CPP, "int _count;").single()
assertEquals(listOf("int" to TokenRole.KEYWORD), line)
}
@Test
fun colorsIfElseReturnThrowAsControlKeywords() {
val line = tokens(SyntaxLanguage.CSHARP, "if (x) return; else throw new Exception();").single()
val controlWords = line.filter { it.second == TokenRole.CONTROL_KEYWORD }.map { it.first }
assertEquals(listOf("if", "return", "else", "throw"), controlWords)
}
@Test
fun colorsForAndWhileAsControlKeywords() {
val line = tokens(SyntaxLanguage.CPP, "for (;;) while (true) {}").single()
val controlWords = line.filter { it.second == TokenRole.CONTROL_KEYWORD }.map { it.first }
assertEquals(listOf("for", "while"), controlWords)
}
@Test
fun leavesIdentifiersUncoloredOnCSharpUsingLine() {
val line = tokens(SyntaxLanguage.CSHARP, "using MyCompany.Utils;").single()
assertEquals(listOf("using" to TokenRole.KEYWORD), line)
}
@Test
fun leavesIdentifierUncoloredOnCppNamespaceDeclarationLine() {
val line = tokens(SyntaxLanguage.CPP, "namespace Foo {").single()
assertEquals(listOf("namespace" to TokenRole.KEYWORD), line)
}
}

View File

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

View File

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

View File

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

View File

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

View 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))
}
}

View File

@ -2,6 +2,7 @@ package com.ubisam.ubgitea.ui.navigation
import androidx.compose.runtime.saveable.SaverScope
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class RouteEncodingTest {
@ -10,6 +11,45 @@ class RouteEncodingTest {
assertEquals(Route.Repositories, RouteEncoding.decode(RouteEncoding.encode(Route.Repositories)))
}
@Test
fun `home route round-trips`() {
assertEquals(Route.Home, RouteEncoding.decode(RouteEncoding.encode(Route.Home)))
}
@Test
fun `work route round-trips`() {
assertEquals(Route.Work, RouteEncoding.decode(RouteEncoding.encode(Route.Work)))
}
@Test
fun `a work item route with a number round-trips for both issues and pull requests`() {
val issue = Route.WorkItems(owner = "owner", name = "repo", number = 42, isPullRequest = false)
assertEquals(issue, RouteEncoding.decode(RouteEncoding.encode(issue)))
val pullRequest = Route.WorkItems(owner = "owner", name = "repo", number = 42, isPullRequest = true)
assertEquals(pullRequest, RouteEncoding.decode(RouteEncoding.encode(pullRequest)))
}
@Test
fun `a work item number that is not numeric decodes to null`() {
val encoded = "workItems" + '' + "owner" + '' + "repo" + '' + "issue" + '' + "abc"
assertNull(RouteEncoding.decodeOrNull(encoded))
}
@Test
fun `a work item number that is zero or negative decodes to null`() {
val zero = "workItems" + '' + "owner" + '' + "repo" + '' + "issue" + '' + "0"
val negative = "workItems" + '' + "owner" + '' + "repo" + '' + "issue" + '' + "-1"
assertNull(RouteEncoding.decodeOrNull(zero))
assertNull(RouteEncoding.decodeOrNull(negative))
}
@Test
fun `restoring an empty stack defaults to Home`() {
val restored = RouteStackSaver.restore(emptyList<String>())
assertEquals(listOf(Route.Home), restored)
}
@Test
fun `branch detail route round-trips including a ref containing a slash`() {
val route = Route.BranchDetail(owner = "team name", name = "repo+#", refKind = "BRANCH", ref = "refs/heads/feature/a b")
@ -28,14 +68,14 @@ class RouteEncodingTest {
}
@Test
fun `an unrecognized encoding falls back to Repositories instead of crashing`() {
assertEquals(Route.Repositories, RouteEncoding.decode("not-a-known-route-type"))
fun `an unrecognized encoding falls back to Home instead of crashing`() {
assertEquals(Route.Home, RouteEncoding.decode("not-a-known-route-type"))
}
@Test
fun `truncated persisted routes fall back instead of indexing missing fields`() {
assertEquals(Route.Repositories, RouteEncoding.decode("branchDetail" + '' + "owner"))
assertEquals(Route.Repositories, RouteEncoding.decode("workItems" + '' + "owner"))
assertEquals(Route.Home, RouteEncoding.decode("branchDetail" + '' + "owner"))
assertEquals(Route.Home, RouteEncoding.decode("workItems" + '' + "owner"))
}
@Test

Some files were not shown because too many files have changed in this diff Show More